diff --git a/.github/workflows/conductor-deploy.yml b/.github/workflows/conductor-deploy.yml new file mode 100644 index 0000000000..6c0a6bb9b8 --- /dev/null +++ b/.github/workflows/conductor-deploy.yml @@ -0,0 +1,49 @@ +name: Deploy to modules-conductor + +permissions: + contents: read + +on: + push: + branches: + - conductor-migration + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Enable Corepack + run: corepack enable + + - name: Use Node.js 💻 + uses: actions/setup-node@v6 + with: + node-version-file: .node-version + cache: yarn + + - name: Install Dependencies 📦 + run: yarn install --immutable + + - name: Build Modules 🔧 + run: yarn workspaces foreach -ptW --from "./src/{bundles,tabs}/*" run build + + - name: Build Manifest + run: yarn buildtools manifest + + - name: Build All Docs + run: yarn build:docs + + - name: include java json + run: cp -r src/java build + + - name: Deploy 🚀 + uses: peaceiris/actions-gh-pages@v4 + with: + deploy_key: ${{ secrets.CONDUCTOR_DEPLOY_KEY }} + external_repository: source-academy/modules-conductor + publish_dir: ./build diff --git a/eslint.config.js b/eslint.config.js index 4aaf6e363e..61556e1938 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -304,6 +304,7 @@ export default defineConfig( } ], 'prefer-const': ['warn', { destructuring: 'all' }], + 'require-yield': 'off', '@sourceacademy/default-import-name': ['warn', { path: 'pathlib' }], '@sourceacademy/no-barrel-imports': ['error', ['lodash']], @@ -443,7 +444,10 @@ export default defineConfig( ignores: ['src/bundles/**/__tests__/*.ts*'], rules: { // Rule doesn't work properly on CI - '@sourceacademy/throw-runtime-error': process.env.CI ? 'off' : 'error' + '@sourceacademy/throw-runtime-error': process.env.CI ? 'off' : ['error', { + // Conductor's own protocol-level errors, unrelated to js-slang's RuntimeSourceError + ignoredNames: ['EvaluatorTypeError', 'EvaluatorRuntimeError'] + }] } }, { diff --git a/lib/buildtools/src/build/docs/__tests__/conductor.test.ts b/lib/buildtools/src/build/docs/__tests__/conductor.test.ts new file mode 100644 index 0000000000..7ab2ff388e --- /dev/null +++ b/lib/buildtools/src/build/docs/__tests__/conductor.test.ts @@ -0,0 +1,217 @@ +import pathlib from 'path'; +import { outDir } from '@sourceacademy/modules-repotools/getGitRoot'; +import type { ResolvedBundle } from '@sourceacademy/modules-repotools/types'; +import * as td from 'typedoc'; +import { describe, expect, it, vi } from 'vitest'; +import { normalizeConductorDocs, normalizeConductorType } from '../conductor/index.js'; +import { initTypedocForJson } from '../typedoc.js'; + +vi.setConfig({ + testTimeout: 10000 +}); + +function createProject() { + return new td.ProjectReflection('test', new td.FileRegistry()); +} + +function register(project: td.ProjectReflection, reflection: T) { + project.registerReflection(reflection, undefined, undefined); + return reflection; +} + +function conductorReference(project: td.ProjectReflection, name: string, qualifiedName = name) { + const reference = td.ReferenceType.createBrokenReference(name, project, '@sourceacademy/conductor'); + reference.qualifiedName = qualifiedName; + return reference; +} + +function dataType(project: td.ProjectReflection, name: string) { + return conductorReference(project, name, `DataType.${name}`); +} + +function typedValue(project: td.ProjectReflection, typeName: string) { + const reference = conductorReference(project, 'ITypedValue'); + reference.typeArguments = [dataType(project, typeName)]; + return reference; +} + +function asyncGenerator(project: td.ProjectReflection, returnType: td.SomeType) { + const reference = td.ReferenceType.createBrokenReference('AsyncGenerator', project, 'typescript'); + reference.typeArguments = [ + new td.IntrinsicType('void'), + returnType, + new td.UnknownType('unknown') + ]; + return reference; +} + +function createParameter( + project: td.ProjectReflection, + signature: td.SignatureReflection, + name: string, + type: td.SomeType +) { + const parameter = register( + project, + new td.ParameterReflection(name, td.ReflectionKind.Parameter, signature) + ); + parameter.type = type; + return parameter; +} + +describe(normalizeConductorType, () => { + it('maps TypedValue numbers to native numbers', () => { + const project = createProject(); + const normalized = normalizeConductorType(typedValue(project, 'NUMBER'), project); + + expect(normalized.stringify(td.TypeContext.none)).toEqual('number'); + }); + + it('maps closures to Function', () => { + const project = createProject(); + const normalized = normalizeConductorType(typedValue(project, 'CLOSURE'), project); + + expect(normalized.stringify(td.TypeContext.none)).toEqual('Function'); + }); + + it('unwraps AsyncGenerator return values', () => { + const project = createProject(); + const normalized = normalizeConductorType( + asyncGenerator(project, typedValue(project, 'NUMBER')), + project + ); + + expect(normalized.stringify(td.TypeContext.none)).toEqual('number'); + }); + + it('leaves native types unchanged', () => { + const project = createProject(); + const nativeType = new td.ArrayType(new td.IntrinsicType('string')); + const normalized = normalizeConductorType(nativeType, project); + + expect(normalized.stringify(td.TypeContext.none)).toEqual('string[]'); + }); +}); + +describe(normalizeConductorDocs, () => { + it('promotes plugin methods and removes Conductor implementation details', () => { + const project = createProject(); + + const implementation = register( + project, + new td.DeclarationReflection('repeat', td.ReflectionKind.Function, project) + ); + project.addChild(implementation); + + const implementationSignature = register( + project, + new td.SignatureReflection('repeat', td.ReflectionKind.CallSignature, implementation) + ); + implementation.signatures = [implementationSignature]; + implementationSignature.comment = new td.Comment([ + { kind: 'text', text: 'Returns a repeated function.' } + ]); + implementationSignature.parameters = [ + createParameter(project, implementationSignature, 'evaluator', conductorReference(project, 'IDataHandler')), + createParameter(project, implementationSignature, 'func', typedValue(project, 'CLOSURE')), + createParameter(project, implementationSignature, 'n', typedValue(project, 'NUMBER')) + ]; + implementationSignature.type = asyncGenerator(project, typedValue(project, 'CLOSURE')); + + const plugin = register( + project, + new td.DeclarationReflection('default', td.ReflectionKind.Class, project) + ); + project.addChild(plugin); + plugin.extendedTypes = [ + conductorReference(project, 'RenamedModulePluginBase') + ]; + + const exportedNames = register( + project, + new td.DeclarationReflection('exportedNames', td.ReflectionKind.Property, plugin) + ); + plugin.addChild(exportedNames); + exportedNames.type = new td.TypeOperatorType( + new td.TupleType([new td.LiteralType('repeat')]), + 'readonly' + ); + + const method = register( + project, + new td.DeclarationReflection('repeat', td.ReflectionKind.Method, plugin) + ); + plugin.addChild(method); + + const methodSignature = register( + project, + new td.SignatureReflection('repeat', td.ReflectionKind.CallSignature, method) + ); + method.signatures = [methodSignature]; + methodSignature.parameters = [ + createParameter(project, methodSignature, 'func', typedValue(project, 'CLOSURE')), + createParameter(project, methodSignature, 'n', typedValue(project, 'NUMBER')) + ]; + methodSignature.type = asyncGenerator(project, typedValue(project, 'CLOSURE')); + + const classesGroup = new td.ReflectionGroup('Classes', project); + classesGroup.children = [plugin]; + const functionsGroup = new td.ReflectionGroup('Functions', project); + functionsGroup.children = [implementation]; + project.groups = [classesGroup, functionsGroup]; + + normalizeConductorDocs(project); + + expect(project.children?.map(child => child.name)).toEqual(['repeat']); + expect(project.groups?.map(group => group.title)).toEqual(['Functions']); + + const publicFunction = project.children?.[0]; + expect(publicFunction?.kind).toEqual(td.ReflectionKind.Function); + + const [signature] = publicFunction?.signatures ?? []; + expect(signature.comment?.summary.map(({ text }) => text).join('')).toEqual('Returns a repeated function.'); + expect(signature.parameters?.map(parameter => [ + parameter.name, + parameter.type?.stringify(td.TypeContext.none) + ])).toEqual([ + ['func', 'Function'], + ['n', 'number'] + ]); + expect(signature.type?.stringify(td.TypeContext.none)).toEqual('Function'); + }); + + it('normalizes the migrated repeat bundle docs', async () => { + const repeatBundle: ResolvedBundle = { + type: 'bundle', + name: 'repeat', + manifest: {}, + directory: pathlib.resolve(import.meta.dirname, '../../../../../../src/bundles/repeat') + }; + const app = await initTypedocForJson(repeatBundle, outDir, td.LogLevel.None); + const project = await app.convert(); + expect(project).toBeDefined(); + + normalizeConductorDocs(project!); + + const names = project!.children?.map(child => child.name); + expect(names).toEqual(expect.arrayContaining(['repeat', 'twice', 'thrice'])); + expect(names).not.toContain('default'); + + const publicFunctions = project!.children?.filter(child => { + return child.kind === td.ReflectionKind.Function + && ['repeat', 'twice', 'thrice'].includes(child.name); + }) ?? []; + const signatureText = publicFunctions + .flatMap(func => func.signatures ?? []) + .map(signature => [ + signature.parameters?.map(parameter => parameter.type?.stringify(td.TypeContext.none)).join(', '), + signature.type?.stringify(td.TypeContext.none) + ].join(' => ')) + .join('\n'); + + expect(signatureText).not.toContain('AsyncGenerator'); + expect(signatureText).not.toContain('ITypedValue'); + expect(publicFunctions.find(func => func.name === 'repeat')?.signatures?.[0].parameters?.map(parameter => parameter.name)) + .toEqual(['func', 'n']); + }); +}); diff --git a/lib/buildtools/src/build/docs/conductor/constants.ts b/lib/buildtools/src/build/docs/conductor/constants.ts new file mode 100644 index 0000000000..20fc3137a7 --- /dev/null +++ b/lib/buildtools/src/build/docs/conductor/constants.ts @@ -0,0 +1,21 @@ +export const CONDUCTOR_PACKAGE = '@sourceacademy/conductor'; +export const TYPED_VALUE_NAMES = new Set(['TypedValue', 'ITypedValue']); +export const SERVICE_PARAMETER_TYPES = new Set([ + 'IDataHandler', + 'IInterfacableEvaluator', + 'IChannel', + 'IConduit' +]); + +export const DATA_TYPE_NAMES = new Set([ + 'VOID', + 'BOOLEAN', + 'NUMBER', + 'CONST_STRING', + 'EMPTY_LIST', + 'PAIR', + 'ARRAY', + 'CLOSURE', + 'OPAQUE', + 'LIST' +]); diff --git a/lib/buildtools/src/build/docs/conductor/index.ts b/lib/buildtools/src/build/docs/conductor/index.ts new file mode 100644 index 0000000000..7d101f64f2 --- /dev/null +++ b/lib/buildtools/src/build/docs/conductor/index.ts @@ -0,0 +1 @@ +export { normalizeConductorDocs, normalizeConductorType } from './normalisation.js'; diff --git a/lib/buildtools/src/build/docs/conductor/normalisation.ts b/lib/buildtools/src/build/docs/conductor/normalisation.ts new file mode 100644 index 0000000000..0f111b42f3 --- /dev/null +++ b/lib/buildtools/src/build/docs/conductor/normalisation.ts @@ -0,0 +1,187 @@ +import * as td from 'typedoc'; +import { TYPED_VALUE_NAMES } from './constants.js'; +import { dataTypeToNativeType, getDataTypeName, isConductorPluginClass, isConductorReference, isServiceParameter, promotePluginMethods, syncGroups } from './utils.js'; + +/** + * Rewrites one call signature from Conductor-facing types to public module-facing types. + */ +function normalizeSignature(signature: td.SignatureReflection, project: td.ProjectReflection) { + if (signature.type) { + project.removeTypeReflections(signature.type); + signature.type = normalizeConductorType(signature.type, project); + } + + signature.parameters = signature.parameters + ?.filter(parameter => !isServiceParameter(parameter)) + .map(parameter => { + if (parameter.type) { + project.removeTypeReflections(parameter.type); + parameter.type = normalizeConductorType(parameter.type, project); + } + return parameter; + }); +} + +/** + * Converts Conductor transport types to the native values that module users see. + */ +export function normalizeConductorType(type: td.SomeType, project: td.ProjectReflection): td.SomeType { + if (type instanceof td.ReferenceType) { + if (isConductorReference(type, TYPED_VALUE_NAMES)) { + return dataTypeToNativeType(getDataTypeName(type.typeArguments?.[0]), project); + } + + if (type.name === 'AsyncGenerator' && type.typeArguments?.[1]) { + return normalizeConductorType(type.typeArguments[1], project); + } + + type.typeArguments = normalizeTypeList(type.typeArguments, project); + return type; + } + + if (type instanceof td.ArrayType) { + type.elementType = normalizeConductorType(type.elementType, project); + return type; + } + + if (type instanceof td.ConditionalType) { + type.checkType = normalizeConductorType(type.checkType, project); + type.extendsType = normalizeConductorType(type.extendsType, project); + type.trueType = normalizeConductorType(type.trueType, project); + type.falseType = normalizeConductorType(type.falseType, project); + return type; + } + + if (type instanceof td.IndexedAccessType) { + type.objectType = normalizeConductorType(type.objectType, project); + type.indexType = normalizeConductorType(type.indexType, project); + return type; + } + + if (type instanceof td.InferredType) { + if (type.constraint) { + type.constraint = normalizeConductorType(type.constraint, project); + } + return type; + } + + if (type instanceof td.IntersectionType || type instanceof td.UnionType) { + type.types = type.types.map(each => normalizeConductorType(each, project)); + return type; + } + + if (type instanceof td.MappedType) { + type.parameterType = normalizeConductorType(type.parameterType, project); + type.templateType = normalizeConductorType(type.templateType, project); + if (type.nameType) { + type.nameType = normalizeConductorType(type.nameType, project); + } + return type; + } + + if (type instanceof td.OptionalType || type instanceof td.RestType) { + type.elementType = normalizeConductorType(type.elementType, project); + return type; + } + + if (type instanceof td.PredicateType) { + if (type.targetType) { + type.targetType = normalizeConductorType(type.targetType, project); + } + return type; + } + + if (type instanceof td.ReflectionType) { + normalizeDeclaration(type.declaration, project); + return type; + } + + if (type instanceof td.TupleType) { + type.elements = type.elements.map(each => normalizeConductorType(each, project)); + return type; + } + + if (type instanceof td.NamedTupleMember) { + type.element = normalizeConductorType(type.element, project); + return type; + } + + if (type instanceof td.TypeOperatorType) { + type.target = normalizeConductorType(type.target, project); + return type; + } + + if (type instanceof td.TemplateLiteralType) { + type.tail = type.tail.map(([tailType, text]) => [ + normalizeConductorType(tailType, project), + text + ]); + return type; + } + + return type; +} + +/** + * Applies signature/type normalization to a declaration and its accessors. + */ +function normalizeDeclaration(declaration: td.DeclarationReflection, project: td.ProjectReflection) { + if (declaration.type) { + project.removeTypeReflections(declaration.type); + declaration.type = normalizeConductorType(declaration.type, project); + } + + declaration.signatures?.forEach(signature => normalizeSignature(signature, project)); + declaration.indexSignatures?.forEach(signature => normalizeSignature(signature, project)); + + if (declaration.getSignature) { + normalizeSignature(declaration.getSignature, project); + } + + if (declaration.setSignature) { + normalizeSignature(declaration.setSignature, project); + } +} + +/** + * Normalizes one container and recursively visits nested modules/namespaces. + */ +function normalizeContainer(container: td.ContainerReflection, project: td.ProjectReflection): boolean { + let mutated = false; + const pluginClasses = container.children?.filter(isConductorPluginClass) ?? []; + + pluginClasses.forEach(plugin => { + promotePluginMethods(container, plugin, project); + project.removeReflection(plugin); + mutated = true; + }); + + container.children?.forEach(child => { + normalizeDeclaration(child, project); + + if (child.isContainer()) { + const childMutated = normalizeContainer(child, project); + mutated = mutated || childMutated; + } + }); + + if (mutated) { + syncGroups(container); + } + + return mutated; +} + +/** + * Rewrites Conductor module implementation details into the public API surface. + */ +export function normalizeConductorDocs(project: td.ProjectReflection) { + normalizeContainer(project, project); +} + +/** + * Normalizes every type argument in a reference or tuple-like TypeDoc type. + */ +function normalizeTypeList(types: td.SomeType[] | undefined, project: td.ProjectReflection) { + return types?.map(type => normalizeConductorType(type, project)); +} diff --git a/lib/buildtools/src/build/docs/conductor/utils.ts b/lib/buildtools/src/build/docs/conductor/utils.ts new file mode 100644 index 0000000000..df908ca13c --- /dev/null +++ b/lib/buildtools/src/build/docs/conductor/utils.ts @@ -0,0 +1,339 @@ +import * as td from 'typedoc'; +import { CONDUCTOR_PACKAGE, DATA_TYPE_NAMES, SERVICE_PARAMETER_TYPES, TYPED_VALUE_NAMES } from './constants.js'; +import { normalizeConductorType } from './normalisation.js'; + +/** + * Narrows a TypeDoc type to references, which is how Conductor transport types appear. + */ +export function isReferenceType(type: td.SomeType | undefined): type is td.ReferenceType { + return type instanceof td.ReferenceType; +} + +/** + * Checks whether a reference points at one of the Conductor symbols this normalizer understands. + */ +export function isConductorReference(type: td.ReferenceType, names: Set) { + return names.has(type.name) && (!type.package || type.package === CONDUCTOR_PACKAGE); +} + +/** + * Detects `DataType.X` references inside `TypedValue`/`ITypedValue` type arguments. + */ +export function isDataTypeReference(type: td.SomeType | undefined): type is td.ReferenceType { + if (!isReferenceType(type)) return false; + + const qualifiedName = type.qualifiedName ?? type.name; + return DATA_TYPE_NAMES.has(type.name) + && (qualifiedName === type.name || qualifiedName === `DataType.${type.name}`); +} + +/** + * Returns the enum member name from a Conductor `DataType.X` type reference. + */ +export function getDataTypeName(type: td.SomeType | undefined) { + return isDataTypeReference(type) ? type.name : undefined; +} + +/** + * Creates an unresolved public-facing type name that TypeDoc renders as plain text. + */ +export function namedType(name: string, project: td.ProjectReflection) { + return td.ReferenceType.createBrokenReference(name, project, undefined); +} + +/** + * Converts a Conductor `DataType` enum member to the type shown to module users. + */ +export function dataTypeToNativeType(dataType: string | undefined, project: td.ProjectReflection): td.SomeType { + switch (dataType) { + case 'VOID': + return new td.IntrinsicType('void'); + case 'BOOLEAN': + return new td.IntrinsicType('boolean'); + case 'NUMBER': + return new td.IntrinsicType('number'); + case 'CONST_STRING': + return new td.IntrinsicType('string'); + case 'EMPTY_LIST': + return new td.LiteralType(null); + case 'PAIR': + return namedType('Pair', project); + case 'ARRAY': + return new td.ArrayType(new td.UnknownType('unknown')); + case 'CLOSURE': + return namedType('Function', project); + case 'OPAQUE': + return new td.UnknownType('unknown'); + case 'LIST': + return namedType('List', project); + default: + return new td.UnknownType('unknown'); + } +} + +/** + * Identifies evaluator/conduit parameters that are implementation plumbing, not public API. + */ +export function isServiceParameter(parameter: td.ParameterReflection) { + if (!isReferenceType(parameter.type)) return false; + return isConductorReference(parameter.type, SERVICE_PARAMETER_TYPES); +} + +/** + * Detects migrated module plugin classes without depending on the concrete base-class name. + */ +export function isConductorPluginClass(reflection: td.DeclarationReflection) { + if (reflection.kind !== td.ReflectionKind.Class) return false; + + const exportedNames = getExportedNames(reflection); + if (exportedNames.size === 0) return false; + + return reflection.children?.some(child => isExportedConductorMethod(child, exportedNames)) ?? false; +} + +/** + * Extracts string literal values from the tuple/union TypeDoc emits for `exportedNames`. + */ +export function getStringLiterals(type: td.SomeType | undefined): string[] { + if (type instanceof td.TypeOperatorType) { + return getStringLiterals(type.target); + } + + if (type instanceof td.TupleType) { + return type.elements.flatMap(getStringLiterals); + } + + if (type instanceof td.UnionType) { + return type.types.flatMap(getStringLiterals); + } + + if (type instanceof td.LiteralType && typeof type.value === 'string') { + return [type.value]; + } + + return []; +} + +/** + * Reads the authoritative list of public module exports from a plugin class. + */ +export function getExportedNames(plugin: td.DeclarationReflection) { + const exportedNames = plugin.children?.find(child => child.name === 'exportedNames'); + return new Set(getStringLiterals(exportedNames?.type)); +} + +/** + * Checks whether a type tree contains Conductor transport wrappers. + */ +export function hasConductorTransportType(type: td.SomeType | undefined): boolean { + if (!type) return false; + + if (type instanceof td.ReferenceType) { + if (isConductorReference(type, TYPED_VALUE_NAMES)) { + return true; + } + + return type.typeArguments?.some(hasConductorTransportType) ?? false; + } + + if (type instanceof td.ArrayType) { + return hasConductorTransportType(type.elementType); + } + + if (type instanceof td.UnionType || type instanceof td.IntersectionType) { + return type.types.some(hasConductorTransportType); + } + + if (type instanceof td.TypeOperatorType) { + return hasConductorTransportType(type.target); + } + + if (type instanceof td.TupleType) { + return type.elements.some(hasConductorTransportType); + } + + return false; +} + +/** + * Determines whether a class method is both publicly exported and Conductor-shaped. + */ +export function isExportedConductorMethod( + child: td.DeclarationReflection, + exportedNames: Set +) { + if (child.kind !== td.ReflectionKind.Method || !exportedNames.has(child.name)) { + return false; + } + + return child.signatures?.some(signature => { + return hasConductorTransportType(signature.type) + || signature.parameters?.some(parameter => hasConductorTransportType(parameter.type)); + }) ?? false; +} + +/** + * Prefers implementation JSDoc over wrapper-method JSDoc when both are available. + */ +export function getSignatureComment( + implementationSignature: td.SignatureReflection | undefined, + pluginSignature: td.SignatureReflection +) { + return implementationSignature?.comment ?? pluginSignature.comment; +} + +/** + * Copies a method parameter onto a public export function signature with normalized type/comment data. + */ +export function cloneParameter( + parameter: td.ParameterReflection, + parent: td.SignatureReflection, + project: td.ProjectReflection, + commentSource: td.ParameterReflection | undefined +) { + const clone = new td.ParameterReflection(parameter.name, td.ReflectionKind.Parameter, parent); + cloneFlags(parameter, clone); + clone.comment = commentSource?.comment ?? parameter.comment; + clone.defaultValue = parameter.defaultValue; + clone.type = parameter.type ? normalizeConductorType(parameter.type, project) : undefined; + project.registerReflection(clone, undefined, undefined); + return clone; +} + +/** + * Copies visibility and modifier flags when replacing TypeDoc reflection nodes. + */ +export function cloneFlags(source: td.Reflection, target: td.Reflection) { + target.flags.fromObject(source.flags.toObject()); +} + +/** + * Builds the public export function signature from the plugin method and matching implementation docs. + */ +export function copyPluginSignature( + target: td.DeclarationReflection, + pluginSignature: td.SignatureReflection, + project: td.ProjectReflection, + implementationSignature: td.SignatureReflection | undefined +) { + const targetSignature = target.signatures?.[0] + ?? new td.SignatureReflection(target.name, td.ReflectionKind.CallSignature, target); + + if (!target.signatures?.includes(targetSignature)) { + target.signatures = [targetSignature]; + project.registerReflection(targetSignature, undefined, undefined); + } + + cloneFlags(pluginSignature, targetSignature); + targetSignature.comment = getSignatureComment(implementationSignature, pluginSignature); + targetSignature.type = pluginSignature.type + ? normalizeConductorType(pluginSignature.type, project) + : undefined; + + targetSignature.parameters = pluginSignature.parameters?.map(parameter => { + const implementationParameter = implementationSignature?.parameters + ?.find(each => each.name === parameter.name); + return cloneParameter(parameter, targetSignature, project, implementationParameter); + }); +} + +/** + * Finds an existing top-level export function reflection for a module export. + */ +export function findPublicFunction(container: td.ContainerReflection, name: string) { + return container.children?.find(child => { + return child.name === name + && child.kind === td.ReflectionKind.Function; + }); +} + +/** + * Creates a new top-level export function reflection when the plugin method has no implementation twin. + */ +export function createPublicFunction( + container: td.ContainerReflection, + name: string, + project: td.ProjectReflection +) { + const reflection = new td.DeclarationReflection(name, td.ReflectionKind.Function, container); + container.addChild(reflection); + project.registerReflection(reflection, undefined, undefined); + return reflection; +} + +/** + * Converts exported plugin methods into top-level functions and merges their public docs. + */ +export function promotePluginMethods( + container: td.ContainerReflection, + plugin: td.DeclarationReflection, + project: td.ProjectReflection +) { + const exportedNames = getExportedNames(plugin); + + plugin.children + ?.filter(child => child.kind === td.ReflectionKind.Method && exportedNames.has(child.name)) + .forEach(method => { + const pluginSignature = method.signatures?.[0]; + if (!pluginSignature) return; + + const publicFunction = findPublicFunction(container, method.name) + ?? createPublicFunction(container, method.name, project); + const implementationSignature = publicFunction.signatures?.[0]; + + copyPluginSignature(publicFunction, pluginSignature, project, implementationSignature); + }); +} + +/** + * Preserves explicit `@group` tags, otherwise derives the default TypeDoc group title. + */ +export function groupNamesFor(reflection: td.DeclarationReflection | td.DocumentReflection) { + const explicitGroups = reflection.comment?.blockTags + ?.filter(tag => tag.tag === '@group') + ?.map(tag => td.Comment.combineDisplayParts(tag.content).trim()) + ?.filter(Boolean); + + return explicitGroups?.length + ? explicitGroups + : [td.ReflectionKind.pluralString(reflection.kind)]; +} + +/** + * Reconciles TypeDoc groups after removing plugin classes and adding promoted functions. + */ +export function syncGroups(container: td.ContainerReflection) { + const children = container.childrenIncludingDocuments ?? []; + const childSet = new Set(children); + const groupedChildren = new Set(); + + container.groups = container.groups + ?.map(group => { + group.children = group.children.filter(child => childSet.has(child)); + group.children.forEach(child => groupedChildren.add(child)); + delete group.categories; + return group; + }) + .filter(group => group.children.length > 0); + + children + .filter(child => !groupedChildren.has(child)) + .forEach(child => { + groupNamesFor(child).forEach(groupName => { + let group = container.groups?.find(each => each.title === groupName); + if (!group) { + group = new td.ReflectionGroup(groupName, container); + const groups = container.groups ?? []; + container.groups = [...groups, group]; + } + + group.children.push(child); + }); + }); + + if (container.groups?.length === 0) { + delete container.groups; + } + + delete container.categories; +} diff --git a/lib/buildtools/src/build/docs/index.ts b/lib/buildtools/src/build/docs/index.ts index 232968c854..9e881f3265 100644 --- a/lib/buildtools/src/build/docs/index.ts +++ b/lib/buildtools/src/build/docs/index.ts @@ -3,6 +3,7 @@ import pathlib from 'path'; import type { BuildResult, ResolvedBundle, ResultType } from '@sourceacademy/modules-repotools/types'; import { mapAsync } from '@sourceacademy/modules-repotools/utils'; import type * as td from 'typedoc'; +import { normalizeConductorDocs } from './conductor/index.js'; import { initTypedocForHtml, initTypedocForJson } from './typedoc.js'; /** @@ -22,6 +23,8 @@ export async function buildSingleBundleDocs(bundle: ResolvedBundle, outDir: stri }; } + normalizeConductorDocs(project); + app.validate(project); await fs.mkdir(`${outDir}/jsons`, { recursive: true }); await app.generateOutputs(project); @@ -88,6 +91,8 @@ export async function buildHtml(bundles: Record, outDir: }; } + normalizeConductorDocs(project); + const htmlPath = pathlib.join(outDir, 'documentation'); await app.generateDocs(project, htmlPath); if (app.logger.hasErrors()) { diff --git a/lib/buildtools/src/commands/commandUtils.ts b/lib/buildtools/src/commands/commandUtils.ts index 1aa92f21b1..85d36084ef 100644 --- a/lib/buildtools/src/commands/commandUtils.ts +++ b/lib/buildtools/src/commands/commandUtils.ts @@ -22,7 +22,7 @@ export const logLevelOption = new Option('--logLevel ', 'Log level that T if (!Number.isNaN(numVal)) { const result = objectValues(LogLevel).some(value => value === numVal); if (result) { - return numVal as unknown as LogLevel; + return numVal; } } diff --git a/lib/repotools/src/testing/__tests__/testing.test.ts b/lib/repotools/src/testing/__tests__/testing.test.ts index 5d83194708..1403334ef1 100644 --- a/lib/repotools/src/testing/__tests__/testing.test.ts +++ b/lib/repotools/src/testing/__tests__/testing.test.ts @@ -32,7 +32,7 @@ describe(testUtils.isTestDirectory, () => { mockedFsGlob.mockImplementationOnce(retValue ? async function* () { yield Promise.resolve(''); return undefined; - // eslint-disable-next-line require-yield, @typescript-eslint/require-await + // eslint-disable-next-line @typescript-eslint/require-await } : async function* () { return undefined; }); diff --git a/lib/testplugin/package.json b/lib/testplugin/package.json new file mode 100644 index 0000000000..1c19fb4e3b --- /dev/null +++ b/lib/testplugin/package.json @@ -0,0 +1,29 @@ +{ + "name": "@sourceacademy/modules-testplugin", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "In-memory Conductor IDataHandler implementation for testing Source Academy modules", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "dependencies": { + "@sourceacademy/conductor": "^0.7.0" + }, + "devDependencies": { + "@sourceacademy/modules-buildtools": "workspace:^", + "eslint": "^9.35.0", + "typescript": "^6.0.2", + "vitest": "4.1.4" + }, + "scripts": { + "build": "tsc --project ./tsconfig.prod.json", + "lint": "eslint src", + "postinstall": "yarn build", + "tsc": "tsc --project ./tsconfig.json", + "test": "buildtools test --project ." + } +} diff --git a/lib/testplugin/src/__tests__/fixtures.ts b/lib/testplugin/src/__tests__/fixtures.ts new file mode 100644 index 0000000000..61c402efcf --- /dev/null +++ b/lib/testplugin/src/__tests__/fixtures.ts @@ -0,0 +1,4 @@ +import { test as baseTest } from 'vitest'; +import { TestDataHandler } from '..'; + +export const test = baseTest.extend('handler', () => new TestDataHandler()); diff --git a/lib/testplugin/src/__tests__/index.test.ts b/lib/testplugin/src/__tests__/index.test.ts new file mode 100644 index 0000000000..e115e3313f --- /dev/null +++ b/lib/testplugin/src/__tests__/index.test.ts @@ -0,0 +1,102 @@ +import { DataType } from '@sourceacademy/conductor/types'; +import { describe, expect } from 'vitest'; +import { + TestDataHandler, + booleanValue, + callClosure, + closureFromFunction, + emptyListValue, + numberValue, + runAsyncGenerator, + stringValue +} from '../index'; +import { test } from './fixtures'; + +describe(TestDataHandler, () => { + test('stores closures as normal JS functions and calls them', async ({ handler }) => { + const closure = await closureFromFunction( + handler, + { + args: [DataType.NUMBER] as const, + returnType: DataType.NUMBER + }, + (x: unknown) => Number(x) + 1 + ); + + expect(handler.closureMap.get(closure.value)).toEqual(expect.any(Function)); + await expect(handler.closure_arity(closure)).resolves.toEqual(1); + await expect(handler.closure_is_vararg(closure)).resolves.toEqual(false); + + const result = await callClosure(handler, closure, [numberValue(41)], DataType.NUMBER); + expect(result).toEqual(numberValue(42)); + }); + + test('stores pairs as JS arrays in the pair map', async ({ handler }) => { + const pair = await handler.pair_make(numberValue(1), stringValue('tail')); + + expect(handler.pairMap.get(pair.value)).toEqual([numberValue(1), stringValue('tail')]); + await expect(handler.pair_head(pair)).resolves.toEqual(numberValue(1)); + + await handler.pair_settail(pair, booleanValue(true)); + await expect(handler.pair_tail(pair)).resolves.toEqual(booleanValue(true)); + await expect(handler.pair_assert(pair, DataType.NUMBER, DataType.BOOLEAN)).resolves.toBeUndefined(); + }); + + test('stores arrays as JS arrays in the array map and enforces element types', async ({ handler }) => { + const array = await handler.array_make(DataType.NUMBER, 3, numberValue(0)); + + expect(handler.arrayMap.get(array.value)).toEqual([ + numberValue(0), + numberValue(0), + numberValue(0) + ]); + + await handler.array_set(array, 1, numberValue(7)); + await expect(handler.array_get(array, 1)).resolves.toEqual(numberValue(7)); + await expect(handler.array_length(array)).resolves.toEqual(3); + await expect(handler.array_type(array)).resolves.toEqual(DataType.NUMBER); + await expect(handler.array_assert(array, DataType.NUMBER, 3)).resolves.toBeUndefined(); + await expect(handler.array_set(array, 0, stringValue('wrong') as never)).rejects.toThrow( + 'Array element expected NUMBER, got CONST_STRING.' + ); + }); + + test('supports lists and accumulation using pair storage', async ({ handler }) => { + const xs = await handler.list(numberValue(1), numberValue(2), numberValue(3)); + const add = await closureFromFunction( + handler, + { + args: [DataType.NUMBER, DataType.NUMBER] as const, + returnType: DataType.NUMBER + }, + (x: unknown, y: unknown) => Number(x) + Number(y) + ); + + await expect(handler.is_list(xs)).resolves.toEqual(true); + await expect(handler.is_list(emptyListValue())).resolves.toEqual(true); + await expect(handler.list_to_vec(xs)).resolves.toEqual([ + numberValue(1), + numberValue(2), + numberValue(3) + ]); + await expect(handler.length(xs)).resolves.toEqual(3); + + const sum = await runAsyncGenerator(handler.accumulate(add, numberValue(0), xs, DataType.NUMBER)); + expect(sum).toEqual(numberValue(6)); + }); + + test('stores and updates opaque values', async ({ handler }) => { + const opaque = await handler.opaque_make({ value: 1 }); + + await expect(handler.opaque_get(opaque)).resolves.toEqual({ value: 1 }); + await handler.opaque_update(opaque, { value: 2 }); + await expect(handler.opaque_get(opaque)).resolves.toEqual({ value: 2 }); + }); + + test('expect initial array to point to the same initial data', async ({ handler }) => { + const array = await handler.array_make(DataType.NUMBER, 3, numberValue(3)); + const objValue = await handler.array_get(array, 0); + objValue.value = 4; + expect(await handler.array_get(array, 2)).toEqual(numberValue(4)); + }); +}); diff --git a/lib/testplugin/src/index.ts b/lib/testplugin/src/index.ts new file mode 100644 index 0000000000..c581e1fad4 --- /dev/null +++ b/lib/testplugin/src/index.ts @@ -0,0 +1,524 @@ +import type { IInterfacableEvaluator } from '@sourceacademy/conductor/runner'; +import { + DataType, + type ExternCallable, + type IFunctionSignature, + type TypedValue +} from '@sourceacademy/conductor/types'; + +type AnyTypedValue = TypedValue; +type PairEntry = [head: AnyTypedValue, tail: AnyTypedValue]; +type ClosureEntry = { + arity: number; + func: ExternCallable; + returnType: DataType; + signature: IFunctionSignature; +}; + +/** + * Drives a Conductor async generator to completion and returns its final value. + */ +export async function runAsyncGenerator(generator: AsyncGenerator): Promise { + let result = await generator.next(); + while (!result.done) { + result = await generator.next(); + } + + return result.value; +} + +/** + * Constructs a Conductor number value. + */ +export function numberValue(value: number): TypedValue { + return { type: DataType.NUMBER, value }; +} + +/** + * Constructs a Conductor boolean value. + */ +export function booleanValue(value: boolean): TypedValue { + return { type: DataType.BOOLEAN, value }; +} + +/** + * Constructs a Conductor string value. + */ +export function stringValue(value: string): TypedValue { + return { type: DataType.CONST_STRING, value }; +} + +/** + * Constructs a Conductor void value. + */ +export function voidValue(): TypedValue { + return { type: DataType.VOID, value: undefined }; +} + +/** + * Constructs a Conductor empty-list value. + */ +export function emptyListValue(): TypedValue { + return { type: DataType.EMPTY_LIST, value: null }; +} + +function isTypedValue(value: unknown): value is AnyTypedValue { + return typeof value === 'object' + && value !== null + && 'type' in value + && 'value' in value; +} + +function typeName(type: DataType) { + return DataType[type] ?? String(type); +} + +function matchesType(value: AnyTypedValue, expected: DataType | undefined) { + if (expected === undefined) return true; + if (expected === DataType.LIST) { + return value.type === DataType.PAIR || value.type === DataType.EMPTY_LIST; + } + + return value.type === expected; +} + +function assertTypedValue(value: AnyTypedValue, expected: DataType | undefined, context: string) { + if (!matchesType(value, expected)) { + throw new Error(`${context} expected ${typeName(expected!)}, got ${typeName(value.type)}.`); + } +} + +function assertIndex(index: number, length: number) { + if (!Number.isInteger(index) || index < 0 || index >= length) { + throw new Error(`Array index ${index} is out of bounds for length ${length}.`); + } +} + +function asPromise(operation: () => T): Promise { + try { + return Promise.resolve(operation()); + } catch (error) { + return Promise.reject(error); + } +} + +/** + * In-memory IDataHandler implementation for tests of Conductor modules. + */ +export class TestDataHandler implements IInterfacableEvaluator { + readonly hasDataInterface = true; + readonly closureMap = new Map>(); + readonly pairMap = new Map(); + readonly arrayMap = new Map(); + readonly opaqueMap = new Map(); + + private nextIdentifier = 1; + private readonly arrayTypeMap = new Map(); + private readonly closureEntryMap = new Map(); + + startEvaluator(_entryPoint: string) { + return Promise.resolve(undefined); + } + + pair_make(head: AnyTypedValue, tail: AnyTypedValue): Promise> { + return asPromise(() => { + const id = this.allocateIdentifier(); + this.pairMap.set(id, [head, tail]); + return { type: DataType.PAIR, value: id as TypedValue['value'] }; + }); + } + + pair_head(p: TypedValue): Promise { + return asPromise(() => this.getPair(p)[0]); + } + + pair_sethead(p: TypedValue, tv: AnyTypedValue): Promise { + return asPromise(() => { + this.getPair(p)[0] = tv; + }); + } + + pair_tail(p: TypedValue): Promise { + return asPromise(() => this.getPair(p)[1]); + } + + pair_settail(p: TypedValue, tv: AnyTypedValue): Promise { + return asPromise(() => { + this.getPair(p)[1] = tv; + }); + } + + pair_assert(p: TypedValue, headType?: DataType, tailType?: DataType): Promise { + return asPromise(() => { + const [head, tail] = this.getPair(p); + assertTypedValue(head, headType, 'Pair head'); + assertTypedValue(tail, tailType, 'Pair tail'); + }); + } + + array_make( + type: T, + len: number, + init?: TypedValue> + ): Promise>> { + return asPromise(() => { + const id = this.allocateIdentifier(); + const initialValue = init ?? this.defaultValue(type); + this.arrayMap.set(id, Array.from({ length: len }, () => initialValue)); + this.arrayTypeMap.set(id, type); + return { + type: DataType.ARRAY, + value: id as TypedValue>['value'] + }; + }); + } + + array_length(a: TypedValue): Promise { + return asPromise(() => this.getArray(a).length); + } + + array_get(a: TypedValue, idx: number): Promise; + array_get( + a: TypedValue, + idx: number + ): Promise>>; + array_get( + a: TypedValue, + idx: number + ): Promise>> { + return asPromise(() => { + const array = this.getArray(a); + assertIndex(idx, array.length); + return array[idx] as TypedValue>; + }); + } + + array_type(a: TypedValue): Promise> { + return asPromise(() => this.getArrayType(a) as NoInfer); + } + + array_set(a: TypedValue, idx: number, tv: AnyTypedValue): Promise; + array_set( + a: TypedValue, + idx: number, + tv: TypedValue> + ): Promise; + array_set( + a: TypedValue, + idx: number, + tv: AnyTypedValue + ): Promise { + return asPromise(() => { + const array = this.getArray(a); + const arrayType = this.getArrayType(a); + assertIndex(idx, array.length); + + if (arrayType !== DataType.VOID) { + assertTypedValue(tv, arrayType, 'Array element'); + } + + array[idx] = tv; + }); + } + + array_assert( + a: TypedValue, + type?: T, + length?: number + ): Promise { + return asPromise(() => { + const array = this.getArray(a); + const arrayType = this.getArrayType(a); + + if (type !== undefined && arrayType !== type) { + throw new Error(`Array expected element type ${typeName(type)}, got ${typeName(arrayType)}.`); + } + + if (length !== undefined && array.length !== length) { + throw new Error(`Array expected length ${length}, got ${array.length}.`); + } + }); + } + + closure_make( + sig: IFunctionSignature, + func: ExternCallable, + _dependsOn?: (AnyTypedValue | null)[] + ): Promise> { + return asPromise(() => { + const id = this.allocateIdentifier(); + this.closureMap.set(id, func); + this.closureEntryMap.set(id, { + arity: sig.args.length, + func, + returnType: sig.returnType, + signature: sig + }); + return { type: DataType.CLOSURE, value: id as TypedValue['value'] }; + }); + } + + closure_is_vararg(_c: TypedValue): Promise { + return Promise.resolve(false); + } + + closure_arity(c: TypedValue): Promise { + return asPromise(() => this.getClosureEntry(c).arity); + } + + async *closure_call( + c: TypedValue, + args: AnyTypedValue[], + returnType: T + ): AsyncGenerator>, undefined> { + const result = yield* this.closure_call_unchecked(c, args); + assertTypedValue(result, returnType, 'Closure return value'); + return result; + } + + async *closure_call_unchecked( + c: TypedValue, + args: AnyTypedValue[] + ): AsyncGenerator>, undefined> { + const closure = this.getClosureEntry(c); + const result = yield* closure.func(...args as Parameters); + return result as TypedValue>; + } + + async closure_arity_assert(c: TypedValue, arity: number): Promise { + const actualArity = await this.closure_arity(c); + if (actualArity !== arity) { + throw new Error(`Closure expected arity ${arity}, got ${actualArity}.`); + } + } + + opaque_make(value: unknown, _immutable?: boolean): Promise> { + return asPromise(() => { + const id = this.allocateIdentifier(); + this.opaqueMap.set(id, value); + return { type: DataType.OPAQUE, value: id as TypedValue['value'] }; + }); + } + + opaque_get(o: TypedValue): Promise { + return asPromise(() => this.getOpaque(o)); + } + + opaque_update(o: TypedValue, value: unknown): Promise { + return asPromise(() => { + this.getOpaque(o); + this.opaqueMap.set(o.value, value); + }); + } + + tie(_dependent: AnyTypedValue, _dependee: AnyTypedValue | null): Promise { + return Promise.resolve(); + } + + untie(_dependent: AnyTypedValue, _dependee: AnyTypedValue | null): Promise { + return Promise.resolve(); + } + + async list(...elements: AnyTypedValue[]): Promise> { + let result: AnyTypedValue = emptyListValue(); + for (let i = elements.length - 1; i >= 0; i -= 1) { + result = await this.pair_make(elements[i], result); + } + + return result; + } + + async is_list(xs: TypedValue): Promise { + try { + await this.list_to_vec(xs); + return true; + } catch { + return false; + } + } + + list_to_vec(xs: TypedValue): Promise { + return asPromise(() => { + const result: AnyTypedValue[] = []; + let current = xs as AnyTypedValue; + + while (current.type !== DataType.EMPTY_LIST) { + if (current.type !== DataType.PAIR) { + throw new Error(`Expected list, got ${typeName(current.type)}.`); + } + + const [head, tail] = this.getPair(current); + result.push(head); + current = tail; + } + + return result; + }); + } + + async *accumulate>( + op: TypedValue, + initial: TypedValue, + sequence: TypedValue, + resultType: T + ): AsyncGenerator, undefined> { + const elements = await this.list_to_vec(sequence); + let result = initial; + + for (let i = elements.length - 1; i >= 0; i -= 1) { + result = yield* this.closure_call(op, [elements[i], result], resultType); + } + + return result; + } + + async length(xs: TypedValue): Promise { + return (await this.list_to_vec(xs)).length; + } + + /** + * Converts a typed value into the raw JS value stored by this handler. + */ + toNative(value: AnyTypedValue): unknown { + switch (value.type) { + case DataType.VOID: + case DataType.BOOLEAN: + case DataType.NUMBER: + case DataType.CONST_STRING: + case DataType.EMPTY_LIST: + return value.value; + case DataType.PAIR: + return this.getPair(value); + case DataType.ARRAY: + return this.getArray(value); + case DataType.CLOSURE: + return this.getClosureEntry(value).func; + case DataType.OPAQUE: + return this.getOpaque(value); + default: + return value; + } + } + + /** + * Converts a raw JS value or existing typed value into a Conductor typed value. + */ + toTyped(type: T, value: unknown): TypedValue { + if (isTypedValue(value)) { + assertTypedValue(value, type, 'Typed value'); + return value as TypedValue; + } + + switch (type) { + case DataType.VOID: + return voidValue() as TypedValue; + case DataType.BOOLEAN: + return booleanValue(Boolean(value)) as TypedValue; + case DataType.NUMBER: + return numberValue(Number(value)) as TypedValue; + case DataType.CONST_STRING: + return stringValue(String(value)) as TypedValue; + case DataType.EMPTY_LIST: + return emptyListValue() as TypedValue; + default: + throw new Error(`Cannot automatically convert JS value to ${typeName(type)}.`); + } + } + + private allocateIdentifier() { + const id = this.nextIdentifier; + this.nextIdentifier += 1; + return id; + } + + private defaultValue(type: T): TypedValue { + switch (type) { + case DataType.VOID: + return voidValue() as TypedValue; + case DataType.BOOLEAN: + return booleanValue(false) as TypedValue; + case DataType.NUMBER: + return numberValue(0) as TypedValue; + case DataType.CONST_STRING: + return stringValue('') as TypedValue; + case DataType.EMPTY_LIST: + return emptyListValue() as TypedValue; + default: + throw new Error(`Array initial value is required for ${typeName(type)} arrays.`); + } + } + + private getPair(pair: TypedValue) { + const entry = this.pairMap.get(pair.value); + if (!entry) { + throw new Error(`Unknown pair identifier ${pair.value}.`); + } + + return entry; + } + + private getArray(array: TypedValue) { + const entry = this.arrayMap.get(array.value); + if (!entry) { + throw new Error(`Unknown array identifier ${array.value}.`); + } + + return entry; + } + + private getArrayType(array: TypedValue) { + const type = this.arrayTypeMap.get(array.value); + if (type === undefined) { + throw new Error(`Unknown array identifier ${array.value}.`); + } + + return type; + } + + private getClosureEntry(closure: TypedValue) { + const entry = this.closureEntryMap.get(closure.value); + if (!entry) { + throw new Error(`Unknown closure identifier ${closure.value}.`); + } + + return entry; + } + + private getOpaque(opaque: TypedValue) { + if (!this.opaqueMap.has(opaque.value)) { + throw new Error(`Unknown opaque identifier ${opaque.value}.`); + } + + return this.opaqueMap.get(opaque.value); + } +} + +/** + * Wraps a normal JS function as a Conductor closure stored in the test handler. + */ +export async function closureFromFunction( + handler: TestDataHandler, + signature: IFunctionSignature, + func: (...args: unknown[]) => unknown | Promise +): Promise> { + return handler.closure_make(signature, async function* (...args: AnyTypedValue[]) { + const result = await func(...args.map(arg => handler.toNative(arg))); + return handler.toTyped(signature.returnType, result); + } as ExternCallable); +} + +/** + * Calls a closure and checks that the returned value has the expected type. + */ +export async function callClosure( + handler: TestDataHandler, + closure: TypedValue, + args: AnyTypedValue[], + returnType?: T +): Promise> { + return runAsyncGenerator( + returnType === undefined + ? handler.closure_call_unchecked(closure, args) + : handler.closure_call(closure, args, returnType) + ); +} diff --git a/lib/testplugin/tsconfig.json b/lib/testplugin/tsconfig.json new file mode 100644 index 0000000000..260fac526a --- /dev/null +++ b/lib/testplugin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["vitest/globals"] + }, + "extends": "../tsconfig.json", + "include": ["./src"] +} diff --git a/lib/testplugin/tsconfig.prod.json b/lib/testplugin/tsconfig.prod.json new file mode 100644 index 0000000000..8c7afb9488 --- /dev/null +++ b/lib/testplugin/tsconfig.prod.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "declaration": true, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "removeComments": false + }, + "extends": "./tsconfig.json", + "exclude": ["**/__tests__"] +} diff --git a/lib/testplugin/vitest.config.ts b/lib/testplugin/vitest.config.ts new file mode 100644 index 0000000000..439efa49b3 --- /dev/null +++ b/lib/testplugin/vitest.config.ts @@ -0,0 +1,17 @@ +import type { ViteUserConfig } from 'vitest/config'; +import rootConfig from '../../vitest.config.js'; + +const config: ViteUserConfig = { + ...rootConfig, + test: { + ...rootConfig.test, + name: 'Modules Test Plugin', + environment: 'node', + root: import.meta.dirname, + include: ['**/__tests__/**/*.test.ts'], + watch: false, + projects: undefined + } +}; + +export default config; diff --git a/src/bundles/binary_tree/package.json b/src/bundles/binary_tree/package.json index e1b7665400..510ed84100 100644 --- a/src/bundles/binary_tree/package.json +++ b/src/bundles/binary_tree/package.json @@ -2,12 +2,10 @@ "name": "@sourceacademy/bundle-binary_tree", "version": "1.0.0", "private": true, - "dependencies": { - "@sourceacademy/modules-lib": "workspace:^", - "js-slang": "catalog:" - }, "devDependencies": { + "@sourceacademy/conductor": "^0.7.0", "@sourceacademy/modules-buildtools": "workspace:^", + "@sourceacademy/modules-testplugin": "workspace:^", "typescript": "catalog:" }, "type": "module", diff --git a/src/bundles/binary_tree/src/__tests__/index.test.ts b/src/bundles/binary_tree/src/__tests__/index.test.ts index 53437a4042..48ac459e44 100644 --- a/src/bundles/binary_tree/src/__tests__/index.test.ts +++ b/src/bundles/binary_tree/src/__tests__/index.test.ts @@ -1,119 +1,204 @@ -import { list } from 'js-slang/dist/stdlib/list'; +import { DataType, type TypedValue } from '@sourceacademy/conductor/types'; +import { TestDataHandler, emptyListValue, numberValue } from '@sourceacademy/modules-testplugin'; import { describe, expect, it } from 'vitest'; import * as funcs from '../functions'; +async function opaqueNumber(handler: TestDataHandler, value: number) { + return handler.opaque_make(value); +} + +async function rawTree( + handler: TestDataHandler, + value: number, + left: TypedValue, + right: TypedValue +) { + return handler.pair_make( + await opaqueNumber(handler, value), + await handler.pair_make(left, await handler.pair_make(right, emptyListValue())) + ); +} + describe(funcs.is_tree, () => { - it('returns false when argument is not a tree', () => { - const arg = [0, [0, null]]; - expect(funcs.is_tree(arg)).toEqual(false); + it('returns false when argument is not a list', async () => { + const handler = new TestDataHandler(); + await expect(funcs.is_tree(handler, numberValue(0))).resolves.toEqual(false); }); - it('returns false when argument is a list of 4 elements', () => { - const arg = list(0, funcs.make_empty_tree(), funcs.make_empty_tree(), funcs.make_empty_tree()); - expect(funcs.is_tree(arg)).toEqual(false); + it('returns false when argument is a list of 4 elements', async () => { + const handler = new TestDataHandler(); + const arg = await handler.list( + numberValue(0), + funcs.make_empty_tree(), + funcs.make_empty_tree(), + funcs.make_empty_tree() + ); + await expect(funcs.is_tree(handler, arg)).resolves.toEqual(false); }); - it('returns false when the branches are not trees', () => { - const not_tree = list(0, 1, 2); - expect(funcs.is_tree(not_tree)).toEqual(false); + it('returns false when the branches are not trees', async () => { + const handler = new TestDataHandler(); + const notTree = await handler.list(numberValue(0), numberValue(1), numberValue(2)); + await expect(funcs.is_tree(handler, notTree)).resolves.toEqual(false); - const also_not_tree = list(1, not_tree, null); - expect(funcs.is_tree(also_not_tree)).toEqual(false); + const alsoNotTree = await handler.list(numberValue(1), notTree, emptyListValue()); + await expect(funcs.is_tree(handler, alsoNotTree)).resolves.toEqual(false); }); - it('returns true when argument is a tree (simple)', () => { - const arg = [0, [null, [null, null]]]; - expect(funcs.is_tree(arg)).toEqual(true); + it('returns true when argument is a tree (simple)', async () => { + const handler = new TestDataHandler(); + const tree = await rawTree(handler, 0, emptyListValue(), emptyListValue()); + await expect(funcs.is_tree(handler, tree)).resolves.toEqual(true); }); - it('returns true when argument is a tree (complex)', () => { - const arg = [ - 0, - [ - [0, [null, [null, null]]], - [ - [ - 1, - [ - null, - [null, null] - ] - ], - null - ] - ] - ]; - expect(funcs.is_tree(arg)).toEqual(true); + it('returns true when argument is a tree (complex)', async () => { + const handler = new TestDataHandler(); + const leftLeaf = await rawTree(handler, 0, emptyListValue(), emptyListValue()); + const rightLeaf = await rawTree(handler, 1, emptyListValue(), emptyListValue()); + const rightSubtree = await handler.pair_make(rightLeaf, emptyListValue()); + const tree = await handler.pair_make( + await opaqueNumber(handler, 0), + await handler.pair_make(leftLeaf, rightSubtree) + ); + await expect(funcs.is_tree(handler, tree)).resolves.toEqual(true); }); }); describe(funcs.make_tree, () => { - it('throws an error when \'left\' is not a tree', () => { - expect(() => funcs.make_tree(0, 0 as any, null)).toThrow('make_tree: Expected binary tree for left, got 0.'); + it('throws when left is not a tree', async () => { + const handler = new TestDataHandler(); + await expect( + funcs.make_tree(handler, await opaqueNumber(handler, 0), numberValue(0) as unknown as TypedValue, funcs.make_empty_tree()) + ).rejects.toThrowError('make_tree expects binary tree for left'); }); - it('throws an error when \'right\' is not a tree', () => { - expect(() => funcs.make_tree(0, null, 0 as any)).toThrow('make_tree: Expected binary tree for right, got 0.'); + it('throws when right is not a tree', async () => { + const handler = new TestDataHandler(); + await expect( + funcs.make_tree(handler, await opaqueNumber(handler, 0), funcs.make_empty_tree(), numberValue(0) as unknown as TypedValue) + ).rejects.toThrowError('make_tree expects binary tree for right'); }); }); describe(funcs.entry, () => { - it('throws when argument is not a tree', () => { - expect(() => funcs.entry(0 as any)).toThrow('entry: Expected binary tree, got 0.'); + it('throws when argument is not a tree', async () => { + const handler = new TestDataHandler(); + await expect(funcs.entry(handler, numberValue(0))).rejects.toThrowError('entry expects binary tree'); }); - it('throws when argument is an empty tree', () => { - expect(() => funcs.entry(null)).toThrow('entry: Expected non-empty binary tree, got null.'); + it('throws when argument is an empty tree', async () => { + const handler = new TestDataHandler(); + await expect(funcs.entry(handler, emptyListValue())).rejects.toThrowError( + 'entry received an empty binary tree!' + ); }); - it('works', () => { - const tree = funcs.make_tree(0, null, null); - expect(funcs.entry(tree)).toEqual(0); + it('works', async () => { + const handler = new TestDataHandler(); + const tree = await funcs.make_tree( + handler, + await opaqueNumber(handler, 0), + funcs.make_empty_tree(), + funcs.make_empty_tree() + ); + const result = await funcs.entry(handler, tree); + await expect(handler.opaque_get(result)).resolves.toEqual(0); }); }); describe(funcs.left_branch, () => { - it('throws when argument is not a tree', () => { - expect(() => funcs.left_branch(0 as any)).toThrow('left_branch: Expected binary tree, got 0.'); - }); - - it('throws when argument is an empty tree', () => { - expect(() => funcs.left_branch(null)).toThrow('left_branch: Expected non-empty binary tree, got null.'); - }); - - it('works (simple)', () => { - const tree = funcs.make_tree(0, null, null); - expect(funcs.left_branch(tree)).toEqual(null); - }); - - it('works (complex)', () => { - const tree = funcs.make_tree(0, funcs.make_tree(1, null, null), null); - expect(funcs.entry(tree)).toEqual(0); - - const leftTree = funcs.left_branch(tree); - expect(funcs.entry(leftTree)).toEqual(1); + it('throws when argument is not a tree', async () => { + const handler = new TestDataHandler(); + await expect(funcs.left_branch(handler, numberValue(0))).rejects.toThrowError('left_branch expects binary tree'); + }); + + it('throws when argument is an empty tree', async () => { + const handler = new TestDataHandler(); + await expect(funcs.left_branch(handler, emptyListValue())).rejects.toThrowError( + 'left_branch received an empty binary tree!' + ); + }); + + it('works (simple)', async () => { + const handler = new TestDataHandler(); + const tree = await funcs.make_tree( + handler, + await opaqueNumber(handler, 0), + funcs.make_empty_tree(), + funcs.make_empty_tree() + ); + const left = await funcs.left_branch(handler, tree); + expect(left.type).toEqual(DataType.EMPTY_LIST); + }); + + it('works (complex)', async () => { + const handler = new TestDataHandler(); + const innerTree = await funcs.make_tree( + handler, + await opaqueNumber(handler, 1), + funcs.make_empty_tree(), + funcs.make_empty_tree() + ); + const tree = await funcs.make_tree( + handler, + await opaqueNumber(handler, 0), + innerTree, + funcs.make_empty_tree() + ); + + await expect(handler.opaque_get(await funcs.entry(handler, tree))).resolves.toEqual(0); + + const leftTree = await funcs.left_branch(handler, tree); + await expect(handler.opaque_get(await funcs.entry(handler, leftTree))).resolves.toEqual(1); }); }); describe(funcs.right_branch, () => { - it('throws when argument is not a tree', () => { - expect(() => funcs.right_branch(0 as any)).toThrow('right_branch: Expected binary tree, got 0.'); - }); - - it('throws when argument is an empty tree', () => { - expect(() => funcs.right_branch(null)).toThrow('right_branch: Expected non-empty binary tree, got null.'); - }); - - it('works (simple)', () => { - const tree = funcs.make_tree(0, null, null); - expect(funcs.right_branch(tree)).toEqual(null); - }); - - it('works (complex)', () => { - const tree = funcs.make_tree(0, funcs.make_tree(1, null, null), funcs.make_tree(2, null, null)); - expect(funcs.entry(tree)).toEqual(0); - - const rightTree = funcs.right_branch(tree); - expect(funcs.entry(rightTree)).toEqual(2); + it('throws when argument is not a tree', async () => { + const handler = new TestDataHandler(); + await expect(funcs.right_branch(handler, numberValue(0))).rejects.toThrowError( + 'right_branch expects binary tree' + ); + }); + + it('throws when argument is an empty tree', async () => { + const handler = new TestDataHandler(); + await expect(funcs.right_branch(handler, emptyListValue())).rejects.toThrowError( + 'right_branch received an empty binary tree!' + ); + }); + + it('works (simple)', async () => { + const handler = new TestDataHandler(); + const tree = await funcs.make_tree( + handler, + await opaqueNumber(handler, 0), + funcs.make_empty_tree(), + funcs.make_empty_tree() + ); + const right = await funcs.right_branch(handler, tree); + expect(right.type).toEqual(DataType.EMPTY_LIST); + }); + + it('works (complex)', async () => { + const handler = new TestDataHandler(); + const leftTree = await funcs.make_tree( + handler, + await opaqueNumber(handler, 1), + funcs.make_empty_tree(), + funcs.make_empty_tree() + ); + const rightTree = await funcs.make_tree( + handler, + await opaqueNumber(handler, 2), + funcs.make_empty_tree(), + funcs.make_empty_tree() + ); + const tree = await funcs.make_tree(handler, await opaqueNumber(handler, 0), leftTree, rightTree); + + await expect(handler.opaque_get(await funcs.entry(handler, tree))).resolves.toEqual(0); + + const right = await funcs.right_branch(handler, tree); + await expect(handler.opaque_get(await funcs.entry(handler, right))).resolves.toEqual(2); }); }); diff --git a/src/bundles/binary_tree/src/functions.ts b/src/bundles/binary_tree/src/functions.ts index 9f479edebe..87ad742e9d 100644 --- a/src/bundles/binary_tree/src/functions.ts +++ b/src/bundles/binary_tree/src/functions.ts @@ -1,5 +1,6 @@ -import { InvalidParameterTypeError } from '@sourceacademy/modules-lib/errors'; -import { head, is_null, is_pair, tail } from 'js-slang/dist/stdlib/list'; +import { EvaluatorRuntimeError, EvaluatorTypeError } from '@sourceacademy/conductor/common'; +import { DataType, type IDataHandler, type TypedValue } from '@sourceacademy/conductor/types'; +import { mEmptyList } from '@sourceacademy/conductor/util'; import type { BinaryTree, EmptyBinaryTree, NonEmptyBinaryTree } from './types'; /** @@ -10,8 +11,8 @@ import type { BinaryTree, EmptyBinaryTree, NonEmptyBinaryTree } from './types'; * ``` * @returns An empty binary tree */ -export function make_empty_tree(): BinaryTree { - return null; +export function make_empty_tree(): EmptyBinaryTree { + return mEmptyList(); } /** @@ -21,21 +22,29 @@ export function make_empty_tree(): BinaryTree { * const tree = make_tree(1, make_empty_tree(), make_empty_tree()); * display(tree); // Shows "[1, [null, [null, null]]]" in the REPL * ``` + * @param evaluator The Conductor data handler for the running evaluator * @param value Value to be stored in the node * @param left Left subtree of the node * @param right Right subtree of the node * @returns A binary tree */ -export function make_tree(value: any, left: BinaryTree, right: BinaryTree): BinaryTree { - if (!is_tree(left)) { - throw new InvalidParameterTypeError('binary tree', left, make_tree.name, 'left'); +export async function make_tree( + evaluator: IDataHandler, + value: TypedValue, + left: BinaryTree, + right: BinaryTree +): Promise { + if (!await is_tree(evaluator, left)) { + throw new EvaluatorTypeError(`${make_tree.name} expects binary tree for left`, 'binary tree', DataType[left.type]); } - if (!is_tree(right)) { - throw new InvalidParameterTypeError('binary tree', right, make_tree.name, 'right'); + if (!await is_tree(evaluator, right)) { + throw new EvaluatorTypeError(`${make_tree.name} expects binary tree for right`, 'binary tree', DataType[right.type]); } - return [value, [left, [right, null]]]; + const rightPair = await evaluator.pair_make(right, mEmptyList()); + const leftPair = await evaluator.pair_make(left, rightPair); + return evaluator.pair_make(value, leftPair); } /** @@ -46,20 +55,28 @@ export function make_tree(value: any, left: BinaryTree, right: BinaryTree): Bina * const tree = make_tree(1, make_empty_tree(), make_empty_tree()); * display(is_tree(tree)); // Shows "true" in the REPL * ``` - * @param value Value to be tested + * @param evaluator The Conductor data handler for the running evaluator + * @param value Value to be tested. May be of any Conductor DataType. */ -export function is_tree(value: unknown): value is BinaryTree { - if (is_empty_tree(value)) return true; +export async function is_tree(evaluator: IDataHandler, value: TypedValue): Promise { + if (!value) return false; + if (value.type === DataType.EMPTY_LIST) return true; + if (value.type !== DataType.PAIR) return false; - if (!is_pair(value)) return false; + const rest = await evaluator.pair_tail(value); + if (rest.type !== DataType.PAIR) return false; - const left = tail(value); - if (!is_pair(left) || !is_tree(head(left))) return false; + const left = await evaluator.pair_head(rest); + if (!await is_tree(evaluator, left)) return false; - const right = tail(left); - if (!is_pair(right) || !is_tree(head(right))) return false; + const rightRest = await evaluator.pair_tail(rest); + if (rightRest.type !== DataType.PAIR) return false; - return is_null(tail(right)); + const right = await evaluator.pair_head(rightRest); + if (!await is_tree(evaluator, right)) return false; + + const tail = await evaluator.pair_tail(rightRest); + return tail.type === DataType.EMPTY_LIST; } /** @@ -70,21 +87,27 @@ export function is_tree(value: unknown): value is BinaryTree { * const tree = make_tree(1, make_empty_tree(), make_empty_tree()); * display(is_empty_tree(tree)); // Shows "false" in the REPL * ``` - * @param value Value to be tested + * @param value Value to be tested. May be of any Conductor DataType. * @returns bool */ -export function is_empty_tree(value: unknown): value is EmptyBinaryTree { - return value === null; +export function is_empty_tree(value: TypedValue): value is TypedValue { + return value?.type === DataType.EMPTY_LIST; } -function throwIfNotNonEmptyTree(value: unknown, func_name: string): asserts value is NonEmptyBinaryTree { - if (!is_tree(value)) { - throw new InvalidParameterTypeError('binary tree', value, func_name); +async function assertNonEmptyTree( + evaluator: IDataHandler, + value: TypedValue, + funcName: string +): Promise { + if (!value || !await is_tree(evaluator, value)) { + throw new EvaluatorTypeError(`${funcName} expects binary tree`, 'binary tree', value ? DataType[value.type] : 'undefined'); } - if (is_empty_tree(value)) { - throw new InvalidParameterTypeError('non-empty binary tree', value, func_name); + if (value.type !== DataType.PAIR) { + throw new EvaluatorRuntimeError(`${funcName} received an empty binary tree!`); } + + return value; } /** @@ -94,12 +117,13 @@ function throwIfNotNonEmptyTree(value: unknown, func_name: string): asserts valu * const tree = make_tree(1, make_empty_tree(), make_empty_tree()); * display(entry(tree)); // Shows "1" in the REPL * ``` + * @param evaluator The Conductor data handler for the running evaluator * @param t BinaryTree to be accessed * @returns Value */ -export function entry(t: BinaryTree): any { - throwIfNotNonEmptyTree(t, entry.name); - return head(t); +export async function entry(evaluator: IDataHandler, t: TypedValue): Promise> { + const tree = await assertNonEmptyTree(evaluator, t, entry.name); + return (await evaluator.pair_head(tree)) as TypedValue; } /** @@ -109,12 +133,14 @@ export function entry(t: BinaryTree): any { * const tree = make_tree(1, make_tree(2, make_empty_tree(), make_empty_tree()), make_empty_tree()); * display(entry(left_branch(tree))); // Shows "2" in the REPL * ``` + * @param evaluator The Conductor data handler for the running evaluator * @param t BinaryTree to be accessed * @returns BinaryTree */ -export function left_branch(t: BinaryTree): BinaryTree { - throwIfNotNonEmptyTree(t, left_branch.name); - return head(tail(t)!); +export async function left_branch(evaluator: IDataHandler, t: TypedValue): Promise { + const tree = await assertNonEmptyTree(evaluator, t, left_branch.name); + const rest = await evaluator.pair_tail(tree); + return (await evaluator.pair_head(rest as TypedValue)) as BinaryTree; } /** @@ -124,10 +150,13 @@ export function left_branch(t: BinaryTree): BinaryTree { * const tree = make_tree(1, make_empty_tree(), make_tree(2, make_empty_tree(), make_empty_tree())); * display(entry(right_branch(tree))); // Shows "2" in the REPL * ``` + * @param evaluator The Conductor data handler for the running evaluator * @param t BinaryTree to be accessed * @returns BinaryTree */ -export function right_branch(t: BinaryTree): BinaryTree { - throwIfNotNonEmptyTree(t, right_branch.name); - return head(tail(tail(t)!)!); +export async function right_branch(evaluator: IDataHandler, t: TypedValue): Promise { + const tree = await assertNonEmptyTree(evaluator, t, right_branch.name); + const rest = await evaluator.pair_tail(tree); + const rightRest = await evaluator.pair_tail(rest as TypedValue); + return (await evaluator.pair_head(rightRest as TypedValue)) as BinaryTree; } diff --git a/src/bundles/binary_tree/src/index.ts b/src/bundles/binary_tree/src/index.ts index b193994101..568739d8ed 100644 --- a/src/bundles/binary_tree/src/index.ts +++ b/src/bundles/binary_tree/src/index.ts @@ -8,12 +8,74 @@ * @author Joel Lee * @author Loh Xian Ze, Bryan */ -export { - entry, - is_empty_tree, - is_tree, - left_branch, - make_empty_tree, - make_tree, - right_branch +import { EvaluatorRuntimeError } from '@sourceacademy/conductor/common'; +import { BaseModulePlugin, moduleMethod } from '@sourceacademy/conductor/module'; +import { DataType, type TypedValue } from '@sourceacademy/conductor/types'; + +import { + entry as entry_func, + is_empty_tree as is_empty_tree_func, + is_tree as is_tree_func, + left_branch as left_branch_func, + make_empty_tree as make_empty_tree_func, + make_tree as make_tree_func, + right_branch as right_branch_func } from './functions'; + +export default class BinaryTreeModulePlugin extends BaseModulePlugin { + id = 'binary_tree'; + override exportedNames = [ + 'entry', + 'is_empty_tree', + 'is_tree', + 'left_branch', + 'make_empty_tree', + 'make_tree', + 'right_branch' + ] as const; + static override channelAttach = []; + + @moduleMethod([], DataType.EMPTY_LIST) + async* make_empty_tree(): AsyncGenerator, unknown> { + return make_empty_tree_func(); + } + + @moduleMethod([DataType.OPAQUE, DataType.LIST, DataType.LIST], DataType.PAIR) + async* make_tree( + value: TypedValue, + left: TypedValue, + right: TypedValue + ): AsyncGenerator, unknown> { + return await make_tree_func(this.evaluator, value, left, right); + } + + // No declared arg type: is_tree is a predicate that must accept a value of any + // Conductor DataType (not just lists/pairs) and answer false rather than throw. + @moduleMethod([], DataType.BOOLEAN) + async* is_tree(value?: TypedValue): AsyncGenerator, unknown> { + if (!value) throw new EvaluatorRuntimeError('is_tree expects 1 argument, received 0'); + return { type: DataType.BOOLEAN, value: await is_tree_func(this.evaluator, value) }; + } + + // Same reasoning as is_tree: must accept a value of any Conductor DataType. + @moduleMethod([], DataType.BOOLEAN) + async* is_empty_tree(value?: TypedValue): AsyncGenerator, unknown> { + if (!value) throw new EvaluatorRuntimeError('is_empty_tree expects 1 argument, received 0'); + return { type: DataType.BOOLEAN, value: is_empty_tree_func(value) }; + } + + @moduleMethod([DataType.LIST], DataType.OPAQUE) + async* entry(t: TypedValue): AsyncGenerator, unknown> { + return await entry_func(this.evaluator, t); + } + + @moduleMethod([DataType.LIST], DataType.LIST) + async* left_branch(t: TypedValue): AsyncGenerator, unknown> { + return await left_branch_func(this.evaluator, t); + } + + @moduleMethod([DataType.LIST], DataType.LIST) + async* right_branch(t: TypedValue): AsyncGenerator, unknown> { + return await right_branch_func(this.evaluator, t); + } +} diff --git a/src/bundles/binary_tree/src/types.ts b/src/bundles/binary_tree/src/types.ts index 7ce53d2a48..bc53323f5d 100644 --- a/src/bundles/binary_tree/src/types.ts +++ b/src/bundles/binary_tree/src/types.ts @@ -1,11 +1,17 @@ +import type { DataType, TypedValue } from '@sourceacademy/conductor/types'; + /** - * An empty binary tree, represented by the empty list `null` + * An empty binary tree, represented by the Conductor empty list. */ -export type EmptyBinaryTree = null; +export type EmptyBinaryTree = TypedValue; /** - * A binary tree, represented by a list of length 3. + * A non-empty binary tree node, represented by a Conductor Pair of length 3: + * `(entry . (left . (right . empty-list)))`. */ -export type NonEmptyBinaryTree = [any, [BinaryTree, [BinaryTree, null]]]; +export type NonEmptyBinaryTree = TypedValue; -export type BinaryTree = NonEmptyBinaryTree | EmptyBinaryTree; +/** + * A binary tree, represented by a Conductor List of length 3, or the empty list. + */ +export type BinaryTree = TypedValue; diff --git a/src/bundles/binary_tree/tsconfig.json b/src/bundles/binary_tree/tsconfig.json index b2d366b198..c0b4222ae7 100644 --- a/src/bundles/binary_tree/tsconfig.json +++ b/src/bundles/binary_tree/tsconfig.json @@ -5,7 +5,9 @@ ], "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "experimentalDecorators": false, + "emitDecoratorMetadata": false }, "typedocOptions": { "name": "binary_tree" diff --git a/src/bundles/repeat/package.json b/src/bundles/repeat/package.json index cf7feafa7f..fe76cb1ba6 100644 --- a/src/bundles/repeat/package.json +++ b/src/bundles/repeat/package.json @@ -6,7 +6,9 @@ "@sourceacademy/modules-lib": "workspace:^" }, "devDependencies": { + "@sourceacademy/conductor": "^0.7.0", "@sourceacademy/modules-buildtools": "workspace:^", + "@sourceacademy/modules-testplugin": "workspace:^", "js-slang": "catalog:", "typescript": "catalog:" }, diff --git a/src/bundles/repeat/src/__tests__/index.test.ts b/src/bundles/repeat/src/__tests__/index.test.ts index 387446296f..046dc0edc8 100644 --- a/src/bundles/repeat/src/__tests__/index.test.ts +++ b/src/bundles/repeat/src/__tests__/index.test.ts @@ -1,49 +1,80 @@ -import { stringify } from 'js-slang/dist/utils/stringify'; -import { describe, expect, test, vi } from 'vitest'; -import * as funcs from '../functions'; +import { DataType, type TypedValue } from '@sourceacademy/conductor/types'; +import { + TestDataHandler, + callClosure, + closureFromFunction, + numberValue, + runAsyncGenerator +} from '@sourceacademy/modules-testplugin'; +import { describe, expect, it } from 'vitest'; +import { repeat, thrice, twice } from '../functions'; -vi.spyOn(funcs, 'repeat'); +async function makePlusOne(handler: TestDataHandler) { + return closureFromFunction( + handler, + { + args: [DataType.NUMBER] as const, + returnType: DataType.NUMBER + }, + x => Number(x) + 1 + ); +} -describe(funcs.repeat, () => { - test('repeat works correctly and repeats unary function n times', () => { - expect(funcs.repeat((x: number) => x + 1, 5)(1)) - .toEqual(6); - }); +async function callNumberClosure( + handler: TestDataHandler, + closure: TypedValue, + value: number +) { + const result = await callClosure( + handler, + closure, + [numberValue(value)], + DataType.NUMBER + ); + return result.value; +} + +describe(repeat, () => { + it('applies a closure n times', async () => { + const handler = new TestDataHandler(); + const plusOne = await makePlusOne(handler); + const repeated = await runAsyncGenerator(repeat(handler, plusOne, numberValue(5))); - test('returns the identity function when n = 0', () => { - expect(funcs.repeat((x: number) => x + 1, 0)(0)).toEqual(0); + await expect(callNumberClosure(handler, repeated, 1)).resolves.toEqual(6); }); - test('throws an error when the function doesn\'t take 1 parameter', () => { - expect(() => funcs.repeat((x: number, y: number) => x + y, 2)) - .toThrow('repeat: Expected function with 1 parameter, got (x, y) => x + y.'); + it('applies a closure twice', async () => { + const handler = new TestDataHandler(); + const plusOne = await makePlusOne(handler); + const repeated = await runAsyncGenerator(twice(handler, plusOne)); - expect(() => funcs.repeat(() => 2, 2)) - .toThrow('repeat: Expected function with 1 parameter, got () => 2.'); + await expect(callNumberClosure(handler, repeated, 1)).resolves.toEqual(3); }); - test('throws an error when provided incorrect values', () => { - expect(() => funcs.repeat((x: number) => x, -1)) - .toThrow('repeat: Expected integer ≥ 0, got -1.'); + it('applies a closure thrice', async () => { + const handler = new TestDataHandler(); + const plusOne = await makePlusOne(handler); + const repeated = await runAsyncGenerator(thrice(handler, plusOne)); - expect(() => funcs.repeat((x: number) => x, 1.5)) - .toThrow('repeat: Expected integer ≥ 0, got 1.5.'); + await expect(callNumberClosure(handler, repeated, 1)).resolves.toEqual(4); }); - test('repeated function has implementation hidden', () => { - const f = funcs.repeat((x: number) => x, 1); - expect(stringify(f)).toEqual('(x) => func(repeat_internal(func, n - 1)(x))'); + it('returns the identity closure when n = 0', async () => { + const handler = new TestDataHandler(); + const plusOne = await makePlusOne(handler); + const repeated = await runAsyncGenerator(repeat(handler, plusOne, numberValue(0))); + + await expect(callNumberClosure(handler, repeated, 5)).resolves.toEqual(5); }); -}); -test('twice works correctly and repeats function twice', () => { - expect(funcs.twice((x: number) => x + 1)(1)) - .toEqual(3); - expect(funcs.repeat).not.toHaveBeenCalled(); -}); + it('throws an error when provided a negative or non-integer n', async () => { + const handler = new TestDataHandler(); + const plusOne = await makePlusOne(handler); + + await expect(runAsyncGenerator(repeat(handler, plusOne, numberValue(-1)))) + .rejects.toThrow('repeat: Expected integer ≥ 0, got -1.'); -test('thrice works correctly and repeats function thrice', () => { - expect(funcs.thrice((x: number) => x + 1)(1)) - .toEqual(4); - expect(funcs.repeat).not.toHaveBeenCalled(); + await expect(runAsyncGenerator(repeat(handler, plusOne, numberValue(1.5)))) + .rejects.toThrow('repeat: Expected integer ≥ 0, got 1.5.'); + }); }); diff --git a/src/bundles/repeat/src/functions.ts b/src/bundles/repeat/src/functions.ts index 8f39407d01..cad1500dd4 100644 --- a/src/bundles/repeat/src/functions.ts +++ b/src/bundles/repeat/src/functions.ts @@ -3,7 +3,9 @@ * @module repeat */ -import { assertFunctionOfLength, assertNumberWithinRange, callWithoutMetadata } from '@sourceacademy/modules-lib/utilities'; +import { DataType, type IDataHandler, type TypedValue } from '@sourceacademy/conductor/types'; +import { GeneralRuntimeError } from '@sourceacademy/modules-lib/errors'; +import { callWithoutMetadata } from '@sourceacademy/modules-lib/utilities'; /** * Represents a function that takes in 1 parameter and returns a @@ -12,7 +14,9 @@ import { assertFunctionOfLength, assertNumberWithinRange, callWithoutMetadata } type UnaryFunction = (x: T) => T; /** - * Internal implementation of the repeat function that doesn't perform type checking + * Internal implementation of the repeat function that doesn't perform type checking. + * Kept as a synchronous export for consumers (e.g. the rune bundle) that haven't + * migrated to the Conductor closure API. * @hidden */ export function repeat_internal(f: UnaryFunction, n: number): UnaryFunction { @@ -23,52 +27,48 @@ export function repeat_internal(f: UnaryFunction, n: number): UnaryFunctio } /** - * Returns a new function which when applied to an argument, has the same effect - * as applying the specified function to the same argument n times. - * @example - * ``` - * const plusTen = repeat(x => x + 2, 5); - * plusTen(0); // Returns 10 - * ``` - * @param func the function to be repeated - * @param n the number of times to repeat the function - * @returns the new function that has the same effect as func repeated n times + * Returns a new closure which when applied to an argument, has the same effect + * as applying the specified closure to the same argument n times. */ -export function repeat(func: Function, n: number): Function { - assertFunctionOfLength(func, 1, repeat.name); - assertNumberWithinRange(n, repeat.name, 0); +export async function* repeat(evaluator: IDataHandler, func: TypedValue, n: TypedValue): AsyncGenerator, unknown> { + if (!Number.isInteger(n.value) || n.value < 0) { + throw new GeneralRuntimeError(`repeat: Expected integer ≥ 0, got ${n.value}.`); + } - return repeat_internal(func, n); + async function* identity(x: any) { + return x; + } + async function* composition(x: any) { + const recursiveFunc = yield* repeat(evaluator, func, { type: DataType.NUMBER, value: n.value - 1 }); + return yield* evaluator.closure_call_unchecked(func, [ + yield* evaluator.closure_call_unchecked( + recursiveFunc, + [x] + )]); + } + + return await evaluator.closure_make( + { + name: 'function', + args: [DataType.VOID] as const, + returnType: DataType.VOID + }, + n.value === 0 ? identity : composition + ); } /** - * Returns a new function which when applied to an argument, has the same effect - * as applying the specified function to the same argument 2 times. - * @example - * ``` - * const plusTwo = twice(x => x + 1); - * plusTwo(2); // Returns 4 - * ``` - * @param func the function to be repeated - * @returns the new function that has the same effect as `(x => func(func(x)))` + * Returns a new closure which when applied to an argument, has the same effect + * as applying the specified closure to the same argument 2 times. */ -export function twice(func: Function): Function { - assertFunctionOfLength(func, 1, twice.name); - return repeat_internal(func, 2); +export async function* twice(evaluator: IDataHandler, func: TypedValue): AsyncGenerator, unknown> { + return yield* repeat(evaluator, func, { type: DataType.NUMBER, value: 2 }); } /** - * Returns a new function which when applied to an argument, has the same effect - * as applying the specified function to the same argument 3 times. - * @example - * ``` - * const plusNine = thrice(x => x + 3); - * plusNine(0); // Returns 9 - * ``` - * @param func the function to be repeated - * @returns the new function that has the same effect as `(x => func(func(func(x))))` + * Returns a new closure which when applied to an argument, has the same effect + * as applying the specified closure to the same argument 3 times. */ -export function thrice(func: Function): Function { - assertFunctionOfLength(func, 1, thrice.name); - return repeat_internal(func, 3); +export async function* thrice(evaluator: IDataHandler, func: TypedValue): AsyncGenerator, unknown> { + return yield* repeat(evaluator, func, { type: DataType.NUMBER, value: 3 }); } diff --git a/src/bundles/repeat/src/index.ts b/src/bundles/repeat/src/index.ts index ae9130f2d8..195449b409 100644 --- a/src/bundles/repeat/src/index.ts +++ b/src/bundles/repeat/src/index.ts @@ -5,4 +5,66 @@ * @module repeat */ -export { repeat, twice, thrice } from './functions'; +import type { IChannel, IConduit } from '@sourceacademy/conductor/conduit'; +import { BaseModulePlugin, moduleMethod } from '@sourceacademy/conductor/module'; +import type { IInterfacableEvaluator } from '@sourceacademy/conductor/runner'; +import { DataType, type TypedValue } from '@sourceacademy/conductor/types'; + +import { repeat as repeat_func, thrice as thrice_func, twice as twice_func } from './functions'; + +export default class RepeatModulePlugin extends BaseModulePlugin { + id = 'repeat'; + override exportedNames = ['repeat', 'twice', 'thrice'] as const; + static override channelAttach = []; + constructor(conduit: IConduit, channels: IChannel[], evaluator: IInterfacableEvaluator) { + super(conduit, channels, evaluator); + } + /** + * Returns a new function which when applied to an argument, has the same effect + * as applying the specified function to the same argument n times. + * @example + * ``` + * const plusTen = repeat(x => x + 2, 5); + * plusTen(0); // Returns 10 + * ``` + * @param func the function to be repeated + * @param n the number of times to repeat the function + * @returns the new function that has the same effect as func repeated n times + */ + @moduleMethod([DataType.CLOSURE, DataType.NUMBER], DataType.CLOSURE) + async* repeat(func: TypedValue, n: TypedValue): AsyncGenerator, unknown> { + return yield* repeat_func(this.evaluator, func, n); + } + + /** + * Returns a new function which when applied to an argument, has the same effect + * as applying the specified function to the same argument 2 times. + * @example + * ``` + * const plusTwo = twice(x => x + 1); + * plusTwo(2); // Returns 4 + * ``` + * @param func the function to be repeated + * @returns the new function that has the same effect as `(x => func(func(x)))` + */ + @moduleMethod([DataType.CLOSURE], DataType.CLOSURE) + async* twice(func: TypedValue): AsyncGenerator, unknown> { + return yield* twice_func(this.evaluator, func); + } + + /** + * Returns a new function which when applied to an argument, has the same effect + * as applying the specified function to the same argument 3 times. + * @example + * ``` + * const plusNine = thrice(x => x + 3); + * plusNine(0); // Returns 9 + * ``` + * @param func the function to be repeated + * @returns the new function that has the same effect as `(x => func(func(func(x))))` + */ + @moduleMethod([DataType.CLOSURE], DataType.CLOSURE) + async* thrice(func: TypedValue): AsyncGenerator, unknown> { + return yield* thrice_func(this.evaluator, func); + } +} diff --git a/src/bundles/repeat/tsconfig.json b/src/bundles/repeat/tsconfig.json index 8ea6e0b655..5e2d36d63c 100644 --- a/src/bundles/repeat/tsconfig.json +++ b/src/bundles/repeat/tsconfig.json @@ -5,7 +5,9 @@ ], "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "experimentalDecorators": false, + "emitDecoratorMetadata": false }, "typedocOptions": { "name": "repeat" diff --git a/yarn.lock b/yarn.lock index b6df00132c..1bc1f1058b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3924,9 +3924,9 @@ __metadata: version: 0.0.0-use.local resolution: "@sourceacademy/bundle-binary_tree@workspace:src/bundles/binary_tree" dependencies: + "@sourceacademy/conductor": "npm:^0.7.0" "@sourceacademy/modules-buildtools": "workspace:^" - "@sourceacademy/modules-lib": "workspace:^" - js-slang: "catalog:" + "@sourceacademy/modules-testplugin": "workspace:^" typescript: "catalog:" languageName: unknown linkType: soft @@ -4090,8 +4090,10 @@ __metadata: version: 0.0.0-use.local resolution: "@sourceacademy/bundle-repeat@workspace:src/bundles/repeat" dependencies: + "@sourceacademy/conductor": "npm:^0.7.0" "@sourceacademy/modules-buildtools": "workspace:^" "@sourceacademy/modules-lib": "workspace:^" + "@sourceacademy/modules-testplugin": "workspace:^" js-slang: "catalog:" typescript: "catalog:" languageName: unknown @@ -4229,6 +4231,13 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/conductor@npm:^0.7.0": + version: 0.7.1 + resolution: "@sourceacademy/conductor@npm:0.7.1" + checksum: 10c0/293cdd2552eeb6cebd795806d841ef4118eb9aeedbfb1e3000c38032b55466269da31b524e37d58d52a068b96522766642ccc7838a4fbc23c9b244fd04cb44da + languageName: node + linkType: hard + "@sourceacademy/lint-plugin@workspace:^, @sourceacademy/lint-plugin@workspace:lib/lintplugin": version: 0.0.0-use.local resolution: "@sourceacademy/lint-plugin@workspace:lib/lintplugin" @@ -4434,6 +4443,18 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/modules-testplugin@workspace:^, @sourceacademy/modules-testplugin@workspace:lib/testplugin": + version: 0.0.0-use.local + resolution: "@sourceacademy/modules-testplugin@workspace:lib/testplugin" + dependencies: + "@sourceacademy/conductor": "npm:^0.7.0" + "@sourceacademy/modules-buildtools": "workspace:^" + eslint: "npm:^9.35.0" + typescript: "npm:^6.0.2" + vitest: "npm:4.1.4" + languageName: unknown + linkType: soft + "@sourceacademy/modules-typedoc-plugin@workspace:^, @sourceacademy/modules-typedoc-plugin@workspace:lib/typedoc-plugin": version: 0.0.0-use.local resolution: "@sourceacademy/modules-typedoc-plugin@workspace:lib/typedoc-plugin" @@ -6094,6 +6115,20 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.1.4": + version: 4.1.4 + resolution: "@vitest/expect@npm:4.1.4" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.4" + "@vitest/utils": "npm:4.1.4" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/99b53a931366ddc985f26528495ec991fa2ce64018b00a56f989c322553045c5adf17e091eb7a12d786246712f84d36fc88e9d26c852538ff4dd5a6f9cf98715 + languageName: node + linkType: hard + "@vitest/expect@npm:4.1.9": version: 4.1.9 resolution: "@vitest/expect@npm:4.1.9" @@ -6108,6 +6143,25 @@ __metadata: languageName: node linkType: hard +"@vitest/mocker@npm:4.1.4": + version: 4.1.4 + resolution: "@vitest/mocker@npm:4.1.4" + dependencies: + "@vitest/spy": "npm:4.1.4" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/da61ee63743da4bc45df0488c994e284e7059a4005149195744705945d19aeb267c801b1f7d85e71b40f547ff2d5a195175c5d51e8455179c794ce67a019de87 + languageName: node + linkType: hard + "@vitest/mocker@npm:4.1.9": version: 4.1.9 resolution: "@vitest/mocker@npm:4.1.9" @@ -6127,6 +6181,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.1.4": + version: 4.1.4 + resolution: "@vitest/pretty-format@npm:4.1.4" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/14a25c5acd02b1d18f9fab01d884658edb9137008d01025273617fb000e36391e4fda1513e94a257f5e611fb09041a0c042d145a90d359c9e810c0044b12763e + languageName: node + linkType: hard + "@vitest/pretty-format@npm:4.1.9": version: 4.1.9 resolution: "@vitest/pretty-format@npm:4.1.9" @@ -6136,6 +6199,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:4.1.4": + version: 4.1.4 + resolution: "@vitest/runner@npm:4.1.4" + dependencies: + "@vitest/utils": "npm:4.1.4" + pathe: "npm:^2.0.3" + checksum: 10c0/a942ecf2e50e4c380f0d269f87272353dc40fe354357e1ecd0c6568fd37202bb86e33db676f4ad6cc5f1ab30937bba0b278d987729b21a0f22e9827f7f577da2 + languageName: node + linkType: hard + "@vitest/runner@npm:4.1.9": version: 4.1.9 resolution: "@vitest/runner@npm:4.1.9" @@ -6146,6 +6219,18 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:4.1.4": + version: 4.1.4 + resolution: "@vitest/snapshot@npm:4.1.4" + dependencies: + "@vitest/pretty-format": "npm:4.1.4" + "@vitest/utils": "npm:4.1.4" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/9221df7c097665a204c811184ac2f3b89638ecd115344e703e9c4361dabd2ba80be4710ed20d127817d34227a74f21b90725deaecd4632954b492ad258d4913f + languageName: node + linkType: hard + "@vitest/snapshot@npm:4.1.9": version: 4.1.9 resolution: "@vitest/snapshot@npm:4.1.9" @@ -6158,6 +6243,13 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:4.1.4": + version: 4.1.4 + resolution: "@vitest/spy@npm:4.1.4" + checksum: 10c0/1036591947668845e45515d5b66b2095071609c243d2c987d650c71d0a27418e5de75a8b1ad44b7f45c5d97e71176640f0f49da94b32fb3d11e87cdd009bed26 + languageName: node + linkType: hard + "@vitest/spy@npm:4.1.9": version: 4.1.9 resolution: "@vitest/spy@npm:4.1.9" @@ -6182,6 +6274,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.1.4": + version: 4.1.4 + resolution: "@vitest/utils@npm:4.1.4" + dependencies: + "@vitest/pretty-format": "npm:4.1.4" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/7f81db08e5a8db1e83a37a8d64db011ae3a08b5bcc9aa220a6da428385acb75b11c77b169ab7a9f753529cc25ec11406cff6099b92711fda6291f844fb840a4e + languageName: node + linkType: hard + "@vitest/utils@npm:4.1.9": version: 4.1.9 resolution: "@vitest/utils@npm:4.1.9" @@ -17433,7 +17536,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^6.0.3": +"typescript@npm:^6.0.2, typescript@npm:^6.0.3": version: 6.0.3 resolution: "typescript@npm:6.0.3" bin: @@ -17473,7 +17576,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": +"typescript@patch:typescript@npm%3A^6.0.2#optional!builtin, typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": version: 6.0.3 resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" bin: @@ -18204,6 +18307,74 @@ __metadata: languageName: node linkType: hard +"vitest@npm:4.1.4": + version: 4.1.4 + resolution: "vitest@npm:4.1.4" + dependencies: + "@vitest/expect": "npm:4.1.4" + "@vitest/mocker": "npm:4.1.4" + "@vitest/pretty-format": "npm:4.1.4" + "@vitest/runner": "npm:4.1.4" + "@vitest/snapshot": "npm:4.1.4" + "@vitest/spy": "npm:4.1.4" + "@vitest/utils": "npm:4.1.4" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.4 + "@vitest/browser-preview": 4.1.4 + "@vitest/browser-webdriverio": 4.1.4 + "@vitest/coverage-istanbul": 4.1.4 + "@vitest/coverage-v8": 4.1.4 + "@vitest/ui": 4.1.4 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: vitest.mjs + checksum: 10c0/a85288778cf6a6f0222aaac547fc84f917565ba78d1e32df4693226ec93aa8675f549b246b70913e9f1d80a87830b39843f9bd96b39d270e599ff4f71def6260 + languageName: node + linkType: hard + "vitest@npm:4.1.9": version: 4.1.9 resolution: "vitest@npm:4.1.9"