Skip to content
Draft
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
20 changes: 20 additions & 0 deletions packages/cashc/src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ContractNode,
SliceNode,
IntLiteralNode,
TupleAssignmentNode,
} from './ast/AST.js';
import { Symbol, SymbolType } from './ast/SymbolTable.js';
import { Location } from './ast/Location.js';
Expand Down Expand Up @@ -263,6 +264,23 @@ export class AssignTypeError extends TypeError {
}
}

export class DuplicateTupleTargetError extends CashScriptError {
constructor(
node: TupleAssignmentNode,
name: string,
) {
super(node, `Duplicate target '${name}' in tuple destructuring`);
}
}

export class TupleTargetOrderError extends CashScriptError {
constructor(
node: TupleAssignmentNode,
) {
super(node, 'Declaration targets must come before all reassignment targets in a tuple destructuring');
}
}

export class ConstantModificationError extends CashScriptError {
constructor(node: VariableDefinitionNode | ConstantDefinitionNode);
constructor(node: Node, name: string);
Expand All @@ -275,6 +293,8 @@ export class ConstantModificationError extends CashScriptError {
}
}

export class InvalidModifierError extends CashScriptError { }

export class ArrayElementError extends CashScriptError {
constructor(
node: ArrayNode,
Expand Down
7 changes: 6 additions & 1 deletion packages/cashc/src/ast/AST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ export class VariableDefinitionNode extends NonControlStatementNode implements N

export interface TupleAssignmentTarget {
name: string;
type: Type;
// For a fresh-declaration target (`int x`) this is the declared type. For a reassignment target
// (`x`, no type) it is undefined at parse time and filled in from the existing variable's symbol
// during SymbolTableTraversal.
type?: Type;
// True for a reassignment of an already-declared variable (no `typeName` in source).
isReassignment?: boolean;
}

export class TupleAssignmentNode extends NonControlStatementNode {
Expand Down
19 changes: 13 additions & 6 deletions packages/cashc/src/ast/AstBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,9 @@ export default class AstBuilder

visitParameter(ctx: ParameterContext): ParameterNode {
const type = parseType(ctx.typeName().getText());
const modifiers = ctx.modifier_list().map((modifier) => modifier.getText());
const name = ctx.Identifier().getText();
const parameter = new ParameterNode(type, [], name);
const parameter = new ParameterNode(type, modifiers, name);
parameter.location = Location.fromCtx(ctx);
return parameter;
}
Expand Down Expand Up @@ -247,11 +248,17 @@ export default class AstBuilder

visitTupleAssignment(ctx: TupleAssignmentContext): TupleAssignmentNode {
const expression = this.visit(ctx.expression());
const types = ctx.typeName_list();
const targets = ctx.Identifier_list().map((name, i) => ({
name: name.getText(),
type: parseType(types[i].getText()),
}));
// Each target is either `typeName Identifier` (fresh declaration) or `Identifier` (reassignment
// of an existing variable). A reassignment target has no type at parse time; SymbolTableTraversal
// fills it in from the existing variable's symbol.
const targets = ctx.tupleTarget_list().map((target) => {
const typeName = target.typeName();
return {
name: target.Identifier().getText(),
type: typeName ? parseType(typeName.getText()) : undefined,
isReassignment: typeName === null,
};
});
const tupleAssignment = new TupleAssignmentNode(targets, expression);
tupleAssignment.location = Location.fromCtx(ctx);
return tupleAssignment;
Expand Down
1 change: 1 addition & 0 deletions packages/cashc/src/ast/Globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export enum Class {

export enum Modifier {
CONSTANT = 'constant',
UNUSED = 'unused',
}

export const GLOBAL_SYMBOL_TABLE = new SymbolTable();
Expand Down
2 changes: 2 additions & 0 deletions packages/cashc/src/ast/SymbolTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { functionReturnType } from '../utils.js';
export class Symbol {
references: IdentifierNode[] = [];
inlinedFrame?: DebugFrame;
ignoreUnused: boolean = false;

private constructor(
public name: string,
Expand Down Expand Up @@ -99,6 +100,7 @@ export class SymbolTable {
unusedSymbols(): Symbol[] {
return Array.from(this.symbols)
.map((e) => e[1])
.filter((s) => !s.ignoreUnused)
.filter((s) => s.references.length === 0);
}
}
5 changes: 5 additions & 0 deletions packages/cashc/src/cashc-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ program
.option('-s, --size', 'Display the size in bytes of the compiled bytecode.')
.option('-S, --skip-enforce-function-parameter-types', 'Do not enforce function parameter types.')
.option('-L, --skip-enforce-locktime-guard', 'Do not inject a tx.time guard when tx.locktime is used.')
.addOption(
new Option('-O, --optimize-for <target>', 'Optimisation objective: minimise bytecode size or executed op-cost (default).')
.choices(['size', 'opcost']),
)
.addOption(
new Option('-f, --format <format>', 'Specify the format of the output.')
.choices(['json', 'ts'])
Expand All @@ -53,6 +57,7 @@ function run(): void {
const compilerOptions: CompilerOptions = {
enforceFunctionParameterTypes: !opts.skipEnforceFunctionParameterTypes,
enforceLocktimeGuard: !opts.skipEnforceLocktimeGuard,
...(opts.optimizeFor ? { optimizeFor: opts.optimizeFor } : {}),
};

try {
Expand Down
112 changes: 104 additions & 8 deletions packages/cashc/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
ImportResolver,
resolveDependencies,
} from './dependency-resolution.js';
import { sinkDefinitions } from './def-sinking.js';
import { applyStackRescheduling } from './stack-rescheduling.js';
import GenerateTargetTraversal from './generation/GenerateTargetTraversal.js';
import SymbolTableTraversal from './semantic/SymbolTableTraversal.js';
import TypeCheckTraversal from './semantic/TypeCheckTraversal.js';
Expand All @@ -35,12 +37,22 @@ import EnsureFunctionsSafeTraversal from './semantic/EnsureFunctionsSafeTraversa
import InjectLocktimeGuardTraversal from './semantic/InjectLocktimeGuardTraversal.js';
import DeadCodeEliminationTraversal from './semantic/DeadCodeEliminationTraversal.js';
import { LowerGlobalConstantsTraversal } from './semantic/LowerGlobalConstantsTraversal.js';
import { hoistRepeatedConstants } from './constant-hoisting.js';

export const DEFAULT_COMPILER_OPTIONS: CompilerOptions = {
enforceFunctionParameterTypes: true,
enforceLocktimeGuard: true,
// recorded explicitly so artifacts always state the objective they were compiled under
// (see CompilerOptions in @cashscript/utils for the size/opcost trade-off)
optimizeFor: 'opcost',
};

// Above this unoptimised op-count the legacy-optimiser cross-check is skipped automatically.
// The legacy optimiser is near-linear in practice (measured ~0.15s at 10k elements, ~0.5s at
// 30-40k), so this gate bounds the cost of a redundant second optimisation pass on very large
// generated contracts — it is not guarding against asymptotic blowup.
const OPTIMISATION_CROSS_CHECK_MAX_OPS = 30_000;

export interface CompileOptions extends CompilerOptions {
errorListener?: CashScriptErrorListener;
}
Expand Down Expand Up @@ -76,6 +88,24 @@ export const compileFile: (codeFile: PathLike, compilerOptions?: CompileOptions)

export interface InternalCompilerOptions extends CompilerOptions {
disableInlining?: boolean;
// Skip the backwards-compat cross-check that re-optimises the bytecode with the legacy
// ASM-regex optimiser and compares the results. The check is also skipped automatically for
// very large scripts, where the redundant second optimisation pass measurably slows compiles.
disableOptimisationCrossCheck?: boolean;
// Skip def-sinking. Under `optimizeFor: 'size'`, definitions move down to just before their
// first use when that shrinks the bytecode (the compiler keeps the smaller of the sunk and
// unsunk compiles). This flag is for tools that need the source-ordered compile as input.
disableDefSinking?: boolean;
// Skip constant hoisting. Under `optimizeFor: 'size'`, repeated in-body literals are bound to
// locals when that shrinks the bytecode (the compiler keeps the hoisted compile only when it
// is strictly smaller). This flag is for tools that need the literal-shaped compile as input.
disableConstantHoisting?: boolean;
}

// The AST rewrites applied before semantic analysis in a single compile candidate.
interface RewriteFlags {
sinkDefs: boolean;
hoistConstants: boolean;
}

export function compileStringInternal(
Expand All @@ -102,14 +132,58 @@ function compileCode(
resolver: ImportResolver,
compilerOptions: CompileOptions & InternalCompilerOptions,
): Artifact {
const { errorListener, disableInlining, ...artifactCompilerOptions } = compilerOptions;
const optimizeFor = compilerOptions.optimizeFor ?? DEFAULT_COMPILER_OPTIONS.optimizeFor;
if (optimizeFor !== 'size') {
return compileImpl(code, resolver, compilerOptions, { sinkDefs: false, hoistConstants: false });
}

// Each 'size' rewrite helps most contracts but can cost bytes on some, so the 'size' objective
// compiles every enabled combination and keeps the smallest artifact. Candidates are ordered
// so ties resolve conservatively: the sunk compile wins a def-sinking tie (established
// behaviour), and the hoisted compile is only kept when it is strictly smaller.
const sinkOptions = compilerOptions.disableDefSinking ? [false] : [true, false];
const hoistOptions = compilerOptions.disableConstantHoisting ? [false] : [false, true];
const candidates = sinkOptions
.flatMap((sinkDefs) => hoistOptions.map((hoistConstants) => ({ sinkDefs, hoistConstants })));
const compiledBytes = (artifact: Artifact): number => artifact.debug?.bytecode.length ?? artifact.bytecode.length;
return candidates
.map((rewrites) => compileImpl(code, resolver, compilerOptions, rewrites))
.reduce((best, candidate) => (compiledBytes(candidate) < compiledBytes(best) ? candidate : best));
}

function compileImpl(
code: string,
resolver: ImportResolver,
compilerOptions: CompileOptions & InternalCompilerOptions,
rewrites: RewriteFlags,
): Artifact {
const {
errorListener, disableInlining, disableOptimisationCrossCheck,
// consumed in compileCode (which picks the smallest rewrite combination); destructured here
// only to keep them out of the serialized artifact options
// eslint-disable-next-line @typescript-eslint/no-unused-vars
disableDefSinking, disableConstantHoisting,
...artifactCompilerOptions
} = compilerOptions;
const mergedCompilerOptions = { ...DEFAULT_COMPILER_OPTIONS, ...artifactCompilerOptions };

// Lexing + parsing
let ast = parseCode(code, errorListener);
checkVersionConstraints(ast.pragmas);

ast = resolveDependencies(ast, resolver, errorListener) as Ast;

// Under the 'size' objective, bind repeated in-body literals to locals (see CompilerOptions).
// Runs before semantic analysis so the introduced locals get symbols like any other variable.
if (rewrites.hoistConstants) {
ast = hoistRepeatedConstants(ast) as Ast;
}

// Sink variable definitions to just before their first use
if (rewrites.sinkDefs) {
ast = sinkDefinitions(ast) as Ast;
}

if (!ast.contract) throw new MissingContractError();

const constructorParamLength = ast.contract.parameters.length;
Expand All @@ -135,8 +209,7 @@ function compileCode(
ast = ast.accept(traversal) as Ast;

// Bytecode optimisation
const optimisedBytecodeOld = optimiseBytecodeOld(traversal.output);
const optimisationResult = optimiseBytecode(
let optimisationResult = optimiseBytecode(
traversal.output,
sourceMapToLocationData(traversal.sourceMap),
traversal.consoleLogs,
Expand All @@ -146,10 +219,33 @@ function compileCode(
constructorParamLength,
);

if (scriptToAsm(optimisedBytecodeOld) !== scriptToAsm(optimisationResult.script)) {
console.error(scriptToAsm(optimisedBytecodeOld));
console.error(scriptToAsm(optimisationResult.script));
throw new Error('New bytecode optimisation is not backwards compatible, please report this issue to the CashScript team');
// Backwards-compat cross-check against the legacy ASM-regex optimiser. The legacy optimiser is
// near-linear in practice (~0.15s at 10k elements, ~0.5s at 30-40k measured), so the size gate
// is about not paying a redundant second optimisation pass on very large generated contracts,
// not about asymptotic blowup; the new optimiser is exercised by the full test suite either
// way. Skippable explicitly via the disableOptimisationCrossCheck compiler option.
if (!disableOptimisationCrossCheck && traversal.output.length <= OPTIMISATION_CROSS_CHECK_MAX_OPS) {
const optimisedBytecodeOld = optimiseBytecodeOld(traversal.output);
if (scriptToAsm(optimisedBytecodeOld) !== scriptToAsm(optimisationResult.script)) {
console.error(scriptToAsm(optimisedBytecodeOld));
console.error(scriptToAsm(optimisationResult.script));
throw new Error('New bytecode optimisation is not backwards compatible, please report this issue to the CashScript team');
}
}

// Stack rescheduling (opt-in): re-derive straight-line evaluation schedules from the
// dataflow DAG, ranked by the optimizeFor objective. Runs after the legacy-optimiser
// cross-check (which compares pre-reschedule outputs) and is restricted to
// single-function contracts (a function selector makes the entry stack depth
// path-dependent, which the block model does not represent).
let frames = traversal.frames;
if (mergedCompilerOptions.rescheduleStacks && ast.contract!.functions.length === 1) {
({ result: optimisationResult, frames } = applyStackRescheduling(optimisationResult, frames, {
arities: traversal.definedFunctionArities,
mainInArity: ast.contract!.functions[0].parameters.length + constructorParamLength,
objective: mergedCompilerOptions.optimizeFor ?? 'opcost',
constructorParamLength,
}));
}

const debug = {
Expand All @@ -158,7 +254,7 @@ function compileCode(
logs: optimisationResult.logs,
requires: optimisationResult.requires,
sourceTags: generateSourceTags(optimisationResult.sourceTags) || undefined,
functions: traversal.frames.length > 0 ? traversal.frames : undefined,
functions: frames.length > 0 ? frames : undefined,
inlineRanges: generateInlineRanges(optimisationResult.inlineRanges) || undefined,
};

Expand Down
Loading
Loading