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
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json",
"project": ["tsconfig.json", "tsconfig.conductor.json"],
"sourceType": "module"
},
"plugins": [
Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/build-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Build and deploy runner

on:
push:
branches:
- main

jobs:
build:
name: Build runner
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Enable corepack
run: corepack enable

- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Typecheck conductor evaluator
run: yarn typecheck:conductor

- name: Test conductor evaluator
run: yarn test:conductor

- name: Build conductor evaluator bundle
run: yarn build:conductor

- name: Upload artifacts
id: deployment
uses: actions/upload-pages-artifact@v3
with:
path: dist-conductor/

deploy:
needs: build
name: Deploy runner
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# compiled output
/dist
/dist-conductor
/node_modules
tsconfig.tsbuildinfo

Expand Down
9 changes: 9 additions & 0 deletions jest.conductor.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** @type {import('jest').Config} */
export default {
testEnvironment: 'node',
testMatch: ['<rootDir>/src/conductor/**/*.test.ts'],
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: '<rootDir>/tsconfig.conductor.json' }]
}
}
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module.exports = {
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/src/compiler/__tests__/tests/',
'<rootDir>/src/conductor/',
'__utils__'
]
}
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,22 @@
"scripts": {
"build": "tsc --build --force",
"build:lib-info": "tsc --build --force && node dist/compiler/import/build-lib-info.js",
"build:conductor": "rollup -c rollup.config.conductor.mjs",
"typecheck:conductor": "tsc --noEmit -p tsconfig.conductor.json",
"test": "jest",
"test:watch": "jest --watch",
"test:conductor": "node --experimental-vm-modules node_modules/.bin/jest --config jest.conductor.config.mjs",
"jsdoc": "typedoc --plugin typedoc-plugin-rename-defaults",
"eslint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext \".ts\" src",
"format": "prettier --write src/**/*.ts",
"prepare": "husky"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^29.0.3",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"@types/jest": "^29.5.4",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
Expand All @@ -32,13 +40,16 @@
"jest": "^29.6.4",
"jest-environment-jsdom": "^29.6.4",
"prettier": "^3.2.5",
"rollup": "^4.63.5",
"ts-jest": "^29.1.1",
"tslib": "^2.8.1",
"typedoc": "^0.25.12",
"typedoc-plugin-markdown": "^3.17.1",
"typedoc-plugin-rename-defaults": "^0.7.0",
"typescript": "^5.2.2"
},
"dependencies": {
"@sourceacademy/conductor": "^0.8.3",
"@types/lodash": "^4.14.198",
"java-parser": "^2.0.5",
"lodash": "^4.17.21",
Expand Down
21 changes: 21 additions & 0 deletions rollup.config.conductor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import terser from '@rollup/plugin-terser'
import typescript from '@rollup/plugin-typescript'

export default {
input: 'src/conductor/initialise.ts',
output: {
file: 'dist-conductor/index.js',
format: 'iife',
sourcemap: true
},
plugins: [
nodeResolve(),
commonjs(),
json(),
typescript({ tsconfig: 'tsconfig.conductor.json', outDir: undefined, declaration: false }),
terser()
]
}
102 changes: 102 additions & 0 deletions src/compiler/__tests__/extract-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,87 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import { CONSTANT_TAG } from '../../ClassFile/constants/constants'
import { ClassFile } from '../../ClassFile/types'
import { ConstantInfo } from '../../ClassFile/types/constants'
import { generatedLibInfo as libInfo } from '../import/generated-lib-info'
import { extractClassMetaFromBuffer } from '../import/extract-metadata'
import { computeClosure, resolveClassFile } from '../import/lib-closure'
import { collectReferencedClassNames } from '../import/referenced-classes'

const CLASS_ROOT = 'rt'
const hasClassRoot = fs.existsSync(CLASS_ROOT)
const describeWithClassRoot = hasClassRoot ? describe : describe.skip

/** Minimal fake ClassFile with just a constant pool, for collectReferencedClassNames tests. */
function fakeClassFile(constantPool: ConstantInfo[]): ClassFile {
return {
magic: 0xcafebabe,
minorVersion: 0,
majorVersion: 52,
constantPoolCount: constantPool.length,
constantPool,
accessFlags: 0,
thisClass: 0,
superClass: 0,
interfacesCount: 0,
interfaces: [],
fieldsCount: 0,
fields: [],
methodsCount: 0,
methods: [],
attributesCount: 0,
attributes: []
}
}

describe('collectReferencedClassNames', () => {
it('collects a plain (non-array) class entry', () => {
const cf = fakeClassFile([
{ tag: CONSTANT_TAG.Class, nameIndex: 0 }, // dummy at index 0
{ tag: CONSTANT_TAG.Utf8, length: 0, value: 'java/util/Arrays' },
{ tag: CONSTANT_TAG.Class, nameIndex: 1 }
])
expect(collectReferencedClassNames(cf)).toEqual(['java/util/Arrays'])
})

it('unwraps a single-dimension object array descriptor to its component class', () => {
const cf = fakeClassFile([
{ tag: CONSTANT_TAG.Class, nameIndex: 0 },
{ tag: CONSTANT_TAG.Utf8, length: 0, value: '[Ljava/lang/String;' },
{ tag: CONSTANT_TAG.Class, nameIndex: 1 }
])
expect(collectReferencedClassNames(cf)).toEqual(['java/lang/String'])
})

it('unwraps a multi-dimension object array descriptor to its component class', () => {
const cf = fakeClassFile([
{ tag: CONSTANT_TAG.Class, nameIndex: 0 },
{ tag: CONSTANT_TAG.Utf8, length: 0, value: '[[Ljava/lang/String;' },
{ tag: CONSTANT_TAG.Class, nameIndex: 1 }
])
expect(collectReferencedClassNames(cf)).toEqual(['java/lang/String'])
})

it('drops a primitive array descriptor (no classfile needed)', () => {
const cf = fakeClassFile([
{ tag: CONSTANT_TAG.Class, nameIndex: 0 },
{ tag: CONSTANT_TAG.Utf8, length: 0, value: '[I' },
{ tag: CONSTANT_TAG.Class, nameIndex: 1 }
])
expect(collectReferencedClassNames(cf)).toEqual([])
})

it('ignores the dummy sentinel at index 0 and deduplicates', () => {
const cf = fakeClassFile([
{ tag: CONSTANT_TAG.Class, nameIndex: 0 }, // dummy - would crash if not skipped
{ tag: CONSTANT_TAG.Utf8, length: 0, value: 'java/lang/Object' },
{ tag: CONSTANT_TAG.Class, nameIndex: 1 },
{ tag: CONSTANT_TAG.Class, nameIndex: 1 } // same class referenced twice
])
expect(collectReferencedClassNames(cf)).toEqual(['java/lang/Object'])
})
})

describe('generated-lib-info.json', () => {
it('contains the java.lang exception hierarchy', () => {
expect(libInfo['java/lang/NullPointerException']).toBeDefined()
Expand Down Expand Up @@ -88,4 +162,32 @@ describeWithClassRoot('extractClassMeta (against the class tree)', () => {
expect(unresolved).toEqual([])
expect(new Set(Object.keys(metadata))).toEqual(new Set(Object.keys(libInfo)))
})

it('omits referencedClasses entirely unless asked for it', () => {
const file = resolveClassFile(CLASS_ROOT, 'java/util/Arrays')
const defaultMeta = extractClassMetaFromBuffer(fs.readFileSync(file as string))
expect('referencedClasses' in defaultMeta).toBe(false)

const withRefs = extractClassMetaFromBuffer(fs.readFileSync(file as string), {
includeReferencedClasses: true
})
expect(withRefs.referencedClasses).toContain('java/util/DualPivotQuicksort')
})

it('does not follow implementation dependencies by default (type-checker allow-list)', () => {
const { metadata } = computeClosure(CLASS_ROOT)
expect(metadata['java/util/DualPivotQuicksort']).toBeUndefined()
expect(metadata['java/util/TimSort']).toBeUndefined()
expect(metadata['java/util/ComparableTimSort']).toBeUndefined()
})

it('follows implementation dependencies when asked (JVM runtime bundle) - Arrays.sort helpers', () => {
const { metadata, unresolved } = computeClosure(CLASS_ROOT, [], {
followImplementationDependencies: true
})
expect(unresolved).toEqual([])
expect(metadata['java/util/DualPivotQuicksort']).toBeDefined()
expect(metadata['java/util/TimSort']).toBeDefined()
expect(metadata['java/util/ComparableTimSort']).toBeDefined()
})
})
47 changes: 43 additions & 4 deletions src/compiler/binary-writer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as fs from 'fs'
import { CONSTANT_TAG } from '../ClassFile/constants/constants'
import { Class, ClassFile } from '../ClassFile/types'
import {
Expand Down Expand Up @@ -30,6 +29,45 @@ const u1 = 1
const u2 = 2
const u4 = 4

/**
* Hand-rolled UTF-8 encode, used instead of `Buffer`/`TextEncoder` so this
* runs unchanged in every target this gets bundled/compiled for: Node, a
* browser Worker (Conductor evaluator), and Jest's jsdom environment (which,
* unlike real browsers, doesn't expose `TextEncoder` as a global).
*/
function utf8Encode(str: string): number[] {
const bytes: number[] = []
for (let i = 0; i < str.length; i++) {
let codePoint = str.charCodeAt(i)
if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < str.length) {
const next = str.charCodeAt(i + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
codePoint = (codePoint - 0xd800) * 0x400 + (next - 0xdc00) + 0x10000
i++
}
}
if (codePoint < 0x80) {
bytes.push(codePoint)
} else if (codePoint < 0x800) {
bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f))
} else if (codePoint < 0x10000) {
bytes.push(
0xe0 | (codePoint >> 12),
0x80 | ((codePoint >> 6) & 0x3f),
0x80 | (codePoint & 0x3f)
)
} else {
bytes.push(
0xf0 | (codePoint >> 18),
0x80 | ((codePoint >> 12) & 0x3f),
0x80 | ((codePoint >> 6) & 0x3f),
0x80 | (codePoint & 0x3f)
)
}
}
return bytes
}

export class BinaryWriter {
private byteArray: Array<number>
private constantPool: Array<ConstantInfo>
Expand All @@ -43,10 +81,12 @@ export class BinaryWriter {
return this.toBinary(this.normalizeClassFile(classFile))
}

/** Node-only: writes a compiled class to disk. Not used by the browser Conductor evaluator. */
writeBinary(classFile: ClassFile | Class | Array<ClassFile> | Array<Class>, filepath: string) {
const filename = filepath + this.getClassName(classFile) + '.class'
const binary = this.toBinary(this.normalizeClassFile(classFile))
fs.writeFileSync(filename, binary)
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('fs').writeFileSync(filename, binary)
}

private normalizeClassFile(
Expand Down Expand Up @@ -119,8 +159,7 @@ export class BinaryWriter {
}

private writeString(str: string) {
const bytes = Array.from(Buffer.from(str, 'utf8'))
this.writeBytes(bytes)
this.writeBytes(utf8Encode(str))
}

private writeConstant(constant: ConstantInfo) {
Expand Down
8 changes: 8 additions & 0 deletions src/compiler/import/class-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export interface ClassMeta {
superClass: string | null
/** Internal names of directly implemented interfaces. */
interfaces: string[]
/**
* Every class referenced anywhere in this class's constant pool
* (deduplicated, sorted) - a superset of `superClass`/`interfaces` that
* also covers classes touched only from method/field bodies (e.g.
* `Arrays.sort` -> `DualPivotQuicksort`). Only present when extraction is
* asked for it (see `ExtractClassMetaOptions.includeReferencedClasses`).
*/
referencedClasses?: string[]
fields: MemberMeta[]
methods: MemberMeta[]
}
Expand Down
Loading
Loading