diff --git a/eslint.config.js b/eslint.config.js index f31968e8b4..cc0e7a2898 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -285,6 +285,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']], 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..0c87f2184b --- /dev/null +++ b/lib/buildtools/src/build/docs/__tests__/conductor.test.ts @@ -0,0 +1,216 @@ +import pathlib from 'path'; +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.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, 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.ts b/lib/buildtools/src/build/docs/conductor.ts new file mode 100644 index 0000000000..1310c6fa42 --- /dev/null +++ b/lib/buildtools/src/build/docs/conductor.ts @@ -0,0 +1,543 @@ +import * as td from 'typedoc'; + +const CONDUCTOR_PACKAGE = '@sourceacademy/conductor'; +const TYPED_VALUE_NAMES = new Set(['TypedValue', 'ITypedValue']); +const SERVICE_PARAMETER_TYPES = new Set([ + 'IDataHandler', + 'IInterfacableEvaluator', + 'IChannel', + 'IConduit' +]); + +const DATA_TYPE_NAMES = new Set([ + 'VOID', + 'BOOLEAN', + 'NUMBER', + 'CONST_STRING', + 'EMPTY_LIST', + 'PAIR', + 'ARRAY', + 'CLOSURE', + 'OPAQUE', + 'LIST' +]); + +/** + * Narrows a TypeDoc type to references, which is how Conductor transport types appear. + */ +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. + */ +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. + */ +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. + */ +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. + */ +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. + */ +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'); + } +} + +/** + * Copies visibility and modifier flags when replacing TypeDoc reflection nodes. + */ +function cloneFlags(source: td.Reflection, target: td.Reflection) { + target.flags.fromObject(source.flags.toObject()); +} + +/** + * 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)); +} + +/** + * 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; +} + +/** + * Identifies evaluator/conduit parameters that are implementation plumbing, not public API. + */ +function isServiceParameter(parameter: td.ParameterReflection) { + if (!isReferenceType(parameter.type)) return false; + return isConductorReference(parameter.type, SERVICE_PARAMETER_TYPES); +} + +/** + * 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; + }); +} + +/** + * 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); + } +} + +/** + * Detects migrated module plugin classes without depending on the concrete base-class name. + */ +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`. + */ +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. + */ +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. + */ +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. + */ +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. + */ +function getSignatureComment( + implementationSignature: td.SignatureReflection | undefined, + pluginSignature: td.SignatureReflection +) { + return implementationSignature?.comment ?? pluginSignature.comment; +} + +/** + * Copies a method parameter onto a public function signature with normalized type/comment data. + */ +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; +} + +/** + * Builds the public function signature from the plugin method and matching implementation docs. + */ +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 function reflection for a module 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 function reflection when the plugin method has no implementation twin. + */ +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. + */ +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. + */ +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. + */ +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; +} + +/** + * 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); +} diff --git a/lib/buildtools/src/build/docs/index.ts b/lib/buildtools/src/build/docs/index.ts index ffe00f4a00..cc4c6d4564 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 * as td from 'typedoc'; +import { normalizeConductorDocs } from './conductor.js'; import { buildJson } from './json.js'; import { initTypedocForHtml, initTypedocForJson } from './typedoc.js'; @@ -23,6 +24,8 @@ export async function buildSingleBundleDocs(bundle: ResolvedBundle, outDir: stri }; } + normalizeConductorDocs(project); + // TypeDoc expects POSIX paths const directoryAsPosix = bundle.directory.replace(/\\/g, '/'); await app.generateJson(project, `${directoryAsPosix}/dist/docs.json`); @@ -74,6 +77,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/testplugin/package.json b/lib/testplugin/package.json new file mode 100644 index 0000000000..f60ecc7d86 --- /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": "https://github.com/source-academy/conductor" + }, + "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__/index.test.ts b/lib/testplugin/src/__tests__/index.test.ts new file mode 100644 index 0000000000..3a8fb98be3 --- /dev/null +++ b/lib/testplugin/src/__tests__/index.test.ts @@ -0,0 +1,99 @@ +import { DataType } from '@sourceacademy/conductor/types'; +import { describe, expect, it } from 'vitest'; +import { + TestDataHandler, + booleanValue, + callClosure, + closureFromFunction, + emptyListValue, + numberValue, + runAsyncGenerator, + stringValue +} from '../index'; + +describe(TestDataHandler, () => { + it('stores closures as normal JS functions and calls them', async () => { + const handler = new TestDataHandler(); + 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)); + }); + + it('stores pairs as JS arrays in the pair map', async () => { + const handler = new TestDataHandler(); + 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(); + }); + + it('stores arrays as JS arrays in the array map and enforces element types', async () => { + const handler = new TestDataHandler(); + 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.' + ); + }); + + it('supports lists and accumulation using pair storage', async () => { + const handler = new TestDataHandler(); + 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)); + }); + + it('stores and updates opaque values', async () => { + const handler = new TestDataHandler(); + 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 }); + }); +}); diff --git a/lib/testplugin/src/index.ts b/lib/testplugin/src/index.ts new file mode 100644 index 0000000000..5ff4019a16 --- /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 as TypedValue>; + } + + 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 as TypedValue; + } + + 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/repeat/package.json b/src/bundles/repeat/package.json index 32efe86f98..815b97e847 100644 --- a/src/bundles/repeat/package.json +++ b/src/bundles/repeat/package.json @@ -3,7 +3,9 @@ "version": "1.0.0", "private": true, "devDependencies": { + "@sourceacademy/conductor": "https://github.com/source-academy/conductor", "@sourceacademy/modules-buildtools": "workspace:^", + "@sourceacademy/modules-testplugin": "workspace:^", "typescript": "^6.0.2" }, "type": "module", diff --git a/src/bundles/repeat/src/__tests__/index.test.ts b/src/bundles/repeat/src/__tests__/index.test.ts index 3a3cff9e81..0938cef99a 100644 --- a/src/bundles/repeat/src/__tests__/index.test.ts +++ b/src/bundles/repeat/src/__tests__/index.test.ts @@ -1,19 +1,61 @@ -import { expect, test } from 'vitest'; - +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'; -// Test functions -test('repeat works correctly and repeats function n times', () => { - expect(repeat((x: number) => x + 1, 5)(1)) - .toBe(6); -}); +async function makePlusOne(handler: TestDataHandler) { + return closureFromFunction( + handler, + { + args: [DataType.NUMBER] as const, + returnType: DataType.NUMBER + }, + x => Number(x) + 1 + ); +} -test('twice works correctly and repeats function twice', () => { - expect(twice((x: number) => x + 1)(1)) - .toBe(3); -}); +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))); + + await expect(callNumberClosure(handler, repeated, 1)).resolves.toEqual(6); + }); + + it('applies a closure twice', async () => { + const handler = new TestDataHandler(); + const plusOne = await makePlusOne(handler); + const repeated = await runAsyncGenerator(twice(handler, plusOne)); + + await expect(callNumberClosure(handler, repeated, 1)).resolves.toEqual(3); + }); + + it('applies a closure thrice', async () => { + const handler = new TestDataHandler(); + const plusOne = await makePlusOne(handler); + const repeated = await runAsyncGenerator(thrice(handler, plusOne)); -test('thrice works correctly and repeats function thrice', () => { - expect(thrice((x: number) => x + 1)(1)) - .toBe(4); + await expect(callNumberClosure(handler, repeated, 1)).resolves.toEqual(4); + }); }); diff --git a/src/bundles/repeat/src/functions.ts b/src/bundles/repeat/src/functions.ts index 04b659da1b..e36891e23f 100644 --- a/src/bundles/repeat/src/functions.ts +++ b/src/bundles/repeat/src/functions.ts @@ -3,48 +3,35 @@ * @module repeat */ -/** - * 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 - */ -export function repeat(func: Function, n: number): Function { - return n === 0 ? (x: any) => x : (x: any) => func(repeat(func, n - 1)(x)); +import { DataType, type IDataHandler, type TypedValue } from '@sourceacademy/conductor/types'; + +export async function* repeat(evaluator: IDataHandler, func: TypedValue, n: TypedValue): AsyncGenerator, unknown> { + 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)))` - */ -export function twice(func: Function): Function { - return repeat(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))))` - */ -export function thrice(func: Function): Function { - return repeat(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..8ef2a49862 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'; + exportedNames = ['repeat', 'twice', 'thrice'] as const; + static 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 8f8cbaa12e..ecd05915c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3992,7 +3992,9 @@ __metadata: version: 0.0.0-use.local resolution: "@sourceacademy/bundle-repeat@workspace:src/bundles/repeat" dependencies: + "@sourceacademy/conductor": "https://github.com/source-academy/conductor" "@sourceacademy/modules-buildtools": "workspace:^" + "@sourceacademy/modules-testplugin": "workspace:^" typescript: "npm:^6.0.2" languageName: unknown linkType: soft @@ -4123,6 +4125,13 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/conductor@https://github.com/source-academy/conductor": + version: 0.5.0 + resolution: "@sourceacademy/conductor@https://github.com/source-academy/conductor.git#commit=8bda154466fd1e8c97ac0fff01f42bc7d3b196df" + checksum: 10c0/acf42d7cb4d036f2b9a2fe2a0a2da3a5670c0fef2cf705a3a367768854413bcbbdb1e6d9ecfd810f9024f60c52ae0973332c0be14ce2fde1cb6598b0cf202a5b + 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" @@ -4324,6 +4333,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": "https://github.com/source-academy/conductor" + "@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@workspace:.": version: 0.0.0-use.local resolution: "@sourceacademy/modules@workspace:."