diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000..fefec26 --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,110 @@ +local bun_image = "oven/bun:1"; +local node_image = "node:20"; + + +local install_step = { + name: "install", + image: bun_image, + commands: ["bun install --frozen-lockfile"], +}; + +local lint_step = { + name: "lint", + image: bun_image, + commands: ["bun lint:tsc"], +}; + +local build_step = { + name: "build", + image: bun_image, + commands: ["bun run build"], +}; + +local pack_step = { + name: "pack", + image: node_image, + commands: ["npm pack --dry-run"], +}; + +local publish_step = { + name: "publish", + image: node_image, + + environment: { + NPM_TOKEN: { from_secret: "npm_token" } + }, + + commands: [ + "npm set @thilawyn:registry https://git.jvalver.de/api/packages/thilawyn/npm/", + "npm config set -- //git.jvalver.de/api/packages/thilawyn/npm/:_authToken $NPM_TOKEN", + "npm publish", + ], +}; + + +[ + // Lint the whole project when not in master, not in a PR nor on a tag + { + kind: "pipeline", + type: "docker", + name: "lint", + + trigger: { + ref: { + exclude: [ + "refs/heads/master", + "refs/pull/**", + "refs/tags/**", + ] + } + }, + + steps: [ + install_step, + lint_step, + ], + }, + + // Build the package without publishing for pull requests + { + kind: "pipeline", + type: "docker", + name: "build", + + trigger: { + ref: { + include: ["refs/pull/**"] + } + }, + + steps: [ + install_step, + lint_step, + build_step, + pack_step, + ], + }, + + // Build and publish the package for master and tags + { + kind: "pipeline", + type: "docker", + name: "build-publish", + + trigger: { + ref: { + include: [ + "refs/heads/master", + "refs/tags/**", + ] + } + }, + + steps: [ + install_step, + lint_step, + build_step, + publish_step, + ], + }, +] diff --git a/.gitignore b/.gitignore index ceaea36..745264f 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,3 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* - diff --git a/package.json b/package.json new file mode 100644 index 0000000..e2d658f --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "@thilawyn/schemable-classes", + "version": "20231230.0.0", + "type": "module", + "publishConfig": { + "registry": "https://git.jvalver.de/api/packages/thilawyn/npm/" + }, + "files": [ + "./dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/lib.d.mts", + "default": "./dist/lib.mjs" + }, + "require": { + "types": "./dist/lib.d.cts", + "default": "./dist/lib.cjs" + } + } + }, + "scripts": { + "build": "rollup -c", + "lint:tsc": "tsc --noEmit", + "clean:cache": "rm -f tsconfig.tsbuildinfo", + "clean:dist": "rm -rf dist", + "clean:node": "rm -rf node_modules" + }, + "dependencies": { + "type-fest": "^4.9.0" + }, + "devDependencies": { + "bun-types": "latest", + "npm-check-updates": "^16.14.12", + "npm-sort": "^0.0.4", + "rollup": "^4.9.1", + "rollup-plugin-cleanup": "^3.2.1", + "rollup-plugin-ts": "^3.4.5", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..da6b231 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,30 @@ +import { defineConfig } from "rollup" +import cleanup from "rollup-plugin-cleanup" +import ts from "rollup-plugin-ts" +import pkg from "./package.json" assert { type: "json" } + + +export default defineConfig({ + input: "src/index.ts", + + output: [ + { + file: pkg.exports["."].import.default, + format: "esm", + }, + + { + file: pkg.exports["."].require.default, + format: "cjs", + }, + ], + + plugins: [ + ts(), + + cleanup({ + comments: "jsdoc", + extensions: ["ts"], + }), + ], +}) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8351eca --- /dev/null +++ b/src/index.ts @@ -0,0 +1,195 @@ +import { AbstractClass, AbstractConstructor, Opaque, UnionToIntersection } from "type-fest" + + +/** + * Represents the static members of a class. + * @template C - The class type. + */ +export type StaticMembers = { + [Key in keyof C as Key extends "prototype" ? never : Key]: C[Key] +} + + +/** + * Represents a trait that can be applied to a class. + * @template C - The abstract class type. + */ +export type Trait< + C extends AbstractClass +> = Opaque< + TraitApplier, + "thilatrait/Trait" +> + +/** + * Represents the function signature for applying a trait to a parent class. + * @template C - The abstract class type. + */ +export type TraitApplier< + C extends AbstractClass +> = + (Parent: AbstractConstructor) => C + +/** + * Unwraps the type of the class from a given trait. + * @template T - The trait type. + */ +export type UnwrapTraitC = + T extends Trait + ? C + : never + + +/** + * Creates a trait using the provided trait applier function. + * @template C - The abstract class type. + * @param applier - The trait applier function. + * @returns A trait. + * @example + * Creates a trait: + * ```ts + * const Permissible = trait(Parent => { + * abstract class Permissible extends Parent { + * static readonly defaultPermissions: string[] = [] + * permissions: string[] = [] + * + * // Constructor is optional + * // If you wish to use it, make sure it takes any[] as an args array and passes it to the super call. This is necessary for inheritance to work properly. + * // Trait constructors cannot have typed arguments of their own, they only serve to run logic during object instantiation. + * constructor(...args: any[]) { + * super(...args) + * } + * } + * + * return Permissible + * }) + * ``` + * Creates a generic trait: + * ```ts + * const Identifiable = () => + * trait(Parent => { + * abstract class Identifiable extends Parent { + * abstract readonly id: ID + * + * equals(el: Identifiable) { + * return this.id === el.id + * } + * + * // Optional + * constructor(...args: any[]) { + * super(...args) + * } + * } + * + * return Identifiable + * }) + * ``` + * Creates a subtrait: + * ```ts + * const ImplementsIdentifiable = (defaultID: ID) => + * trait(Parent => { + * abstract class ImplementsIdentifiable extends extendsAndExpresses( + * Parent, + * [Identifiable()], + * ) { + * id: ID = defaultID + * + * // Optional + * constructor(...args: any[]) { + * super(...args) + * } + * } + * + * return ImplementsIdentifiable + * }) + * ``` + */ +export function trait< + C extends AbstractClass +>( + applier: TraitApplier +) { + return applier as Trait +} + + +/** + * Extends a class with the given traits and expresses their combined functionality. + * @template C - The abstract class type. + * @template Traits - An array of traits. + * @param extend - The class to extend. + * @param traits - An array of traits to apply. + * @returns A new class type expressing the combined functionality of the base class and traits. + * @example + * Extends a superclass and applies traits: + * ```ts + * class User extends extendsAndExpresses(Entity, [Identifiable(), Permissible]) { + * readonly id: bigint + * + * constructor(id: bigint) { + * super() + * this.id = id + * } + * } + * ``` + */ +export function extendsAndExpresses< + C extends AbstractClass, + Traits extends readonly Trait[], +>( + extend: C, + traits: Traits, +) { + return traits.reduce( + (previous, trait) => trait(previous), + extend, + ) as ( + AbstractClass< + InstanceType & + UnionToIntersection< + InstanceType< + UnwrapTraitC< + Traits[number] + > + > + >, + + ConstructorParameters + > & + + StaticMembers & + StaticMembers< + UnionToIntersection< + UnwrapTraitC< + Traits[number] + > + > + > + ) +} + +/** + * Expresses the combined functionality of multiple traits. + * @template Traits - An array of trait. + * @param traits - An array of trait to apply. + * @returns A new class type expressing the combined functionality of the traits. + * @example + * Applies traits to a class: + * ```ts + * class User extends expresses(Identifiable(), Permissible) { + * readonly id: bigint + * + * constructor(id: bigint) { + * super() + * this.id = id + * } + * } + * ``` + */ +export function expresses< + Traits extends readonly Trait[], +>( + ...traits: Traits +) { + return extendsAndExpresses(Object, traits) +} diff --git a/src/legacy/tests.ts b/src/legacy/tests.ts new file mode 100644 index 0000000..e86528d --- /dev/null +++ b/src/legacy/tests.ts @@ -0,0 +1,74 @@ +import { AbstractClass } from "type-fest" +import { expresses } from "./trait" + + +function inspectClass(class_: AbstractClass) { + Object.getOwnPropertyNames(class_).forEach(name => { + console.log( + "[static]", + name, + Object.getOwnPropertyDescriptor(class_, name) + ) + }) + + Object.getOwnPropertyNames(class_.prototype).forEach(name => { + console.log( + "[prototype]", + name, + Object.getOwnPropertyDescriptor(class_.prototype, name) + ) + }) +} + + +abstract class Identified { + abstract id: ID + + equals(el: Identified) { + return this.id === el.id + } + + // initializer() { + // console.log("Identified initializer") + // } +} + +class ImplementsIdentifiable extends Identified { + id!: ID +} + + +abstract class Permissible { + static readonly defaultPermissions: string[] = [] + permissions: string[] = [] + // permissions!: string[] + + constructor() { + console.log("Permissible constructor") + } + + initializer() { + console.log("Permissible initializer") + this.permissions = [] + } +} + + +class User extends expresses( + Identified as typeof Identified, + // Identified, + Permissible, +) { + readonly id: bigint + + constructor(id: bigint) { + super() + this.id = id + } +} + +const user1 = new User(BigInt(1)) +const user2 = new User(BigInt(2)) + +console.log(user1) +console.log(user1.equals(user2)) diff --git a/src/legacy/trait.ts b/src/legacy/trait.ts new file mode 100644 index 0000000..10cc87b --- /dev/null +++ b/src/legacy/trait.ts @@ -0,0 +1,126 @@ +import { AbstractClass, Class, UnionToIntersection } from "type-fest" +import { StaticMembers, copyProperties, getInheritanceHierarchy } from "./util/class" + + +/** + * Represents a trait that can be used to define common behavior + * for classes and abstract classes. + * @typeParam T - The type of the trait. + */ +export type Trait = + AbstractClass + + +/** + * Creates a link class that expresses the given traits. + * @param traits - An array of traits to be expressed by the link class. + * @returns A dynamically created class that expresses the given traits. + * @typeParam Traits - An array of traits that the link class expresses. + */ +export function expresses< + Traits extends readonly Trait[] +>( + ...traits: Traits +) { + return makeLinkClass(traits) +} + + +/** + * Creates a link class that extends a base class and expresses the given traits. + * @param extend - The base class or abstract class to extend. + * @param traits - An array of traits to be expressed by the link class. + * @returns A dynamically created class that extends the given base class and expresses the given traits. + * @typeParam C - The type of the base class to extend. + * @typeParam Traits - An array of traits that the link class expresses. + */ +export function extendsAndExpresses< + C extends Class + | AbstractClass, + Traits extends readonly Trait[], +>( + extend: C, + ...traits: Traits +) { + return makeLinkClass(traits, extend) +} + + +/** + * Creates a link class that expresses the given traits and optionally extends a base class. + * @param traits - An array of traits to be expressed by the link class. + * @param extend - The base class or abstract class to extend (optional). + * @returns A dynamically created class that expresses the given traits and extends the base class. + * @typeParam Traits - An array of traits that the link class expresses. + * @typeParam C - The type of the base class to extend (optional). + */ +export function makeLinkClass< + Traits extends readonly Trait[], + C extends Class + | AbstractClass + | undefined = undefined, +>( + traits: Traits, + extend?: C, +) { + const class_ = extend + ? class extends extend { + constructor(...args: any[]) { + super(...args) + + traits.forEach(trait => { + trait.prototype.initializer?.call(this) + }) + } + } + : class { + constructor() { + traits.forEach(trait => { + trait.prototype.initializer?.call(this) + }) + } + } + + traits.forEach(trait => { + getInheritanceHierarchy(trait).forEach(current => { + copyProperties( + current, + class_, + ["name", "length"], + ["constructor"], + ) + }) + }) + + return class_ as unknown as ( + (C extends Class | AbstractClass + ? ( + AbstractClass< + InstanceType & + UnionToIntersection< + InstanceType< + Traits[number] + > + >, + + ConstructorParameters + > & + + StaticMembers + ) + : Trait< + UnionToIntersection< + InstanceType< + Traits[number] + > + > + > + ) & + + StaticMembers< + UnionToIntersection< + Traits[number] + > + > + ) +} diff --git a/src/legacy/util/class.ts b/src/legacy/util/class.ts new file mode 100644 index 0000000..518c960 --- /dev/null +++ b/src/legacy/util/class.ts @@ -0,0 +1,109 @@ +import { AbstractClass } from "type-fest" + + +/** + * Represents the static members of a class. + * + * @template C - The type of the class for which static members are extracted. + * @typeparam The static members of the class. + */ +export type StaticMembers = Pick + + +/** + * Flattens the inheritance hierarchy of a given class by copying all properties + * from its superclass chain into a single object. + * + * @template C - The type of the class to be flattened, extending AbstractClass. + * @param C - The class to be flattened. + * @returns A new class with properties flattened from the entire inheritance hierarchy. + */ +export function flattenClass< + C extends AbstractClass +>(class_: C) { + const flattenedClass = class {} as unknown as C + + getInheritanceHierarchy(class_).forEach(current => { + copyProperties(current, flattenedClass) + }) + + copyProperty(class_, flattenedClass, "name") + copyProperty(class_.prototype, flattenedClass.prototype, "constructor") + + return flattenedClass +} + + +/** + * Retrieves the inheritance hierarchy of a given class, including itself. + * + * @param class_ - The class for which the inheritance hierarchy is retrieved. + * @returns An array representing the inheritance hierarchy, ordered from the furthest in the hierarchy to `class_` itself. + */ +export function getInheritanceHierarchy( + class_: AbstractClass +): AbstractClass[] { + const parent = Object.getPrototypeOf(class_) + + return isClass(parent) + ? [...getInheritanceHierarchy(parent), class_] + : [class_] +} + + +/** + * Checks if a given element appears to be a class based on its string representation. + * + * @param el - The element to check for being a class. + * @returns `true` if the element is likely a class; otherwise, `false`. + */ +export function isClass(el: { toString: () => string }) { + return Boolean(el.toString().match(/^class(?: [.\S]+)?(?: extends [.\S]+)? {[\s\S]*}$/)) +} + + +/** + * Copies properties from one class to another, including static and prototype properties. + * + * @param from - The source class to copy properties from. + * @param to - The destination class to copy properties to. + */ +export function copyProperties( + from: AbstractClass, + to: AbstractClass, + ignoreKeys: string[] = [], + ignorePrototypeKeys: string[] = [], +) { + Object.getOwnPropertyNames(from).forEach(name => { + if (name === "prototype" + || ignoreKeys.find(v => v === name) + ) + return + + // console.log(from, to, name, Object.getOwnPropertyDescriptor(from, name)) + + copyProperty(from, to, name) + }) + + Object.getOwnPropertyNames(from.prototype).forEach(name => { + if (ignorePrototypeKeys.find(v => v === name)) + return + + // console.log(from, to, name, Object.getOwnPropertyDescriptor(from, name)) + + copyProperty(from.prototype, to.prototype, name) + }) +} + + +export function copyProperty( + from: unknown, + to: unknown, + name: string, +) { + Object.defineProperty( + to, + name, + Object.getOwnPropertyDescriptor(from, name) || Object.create(null), + ) +} diff --git a/src/tests.ts b/src/tests.ts new file mode 100644 index 0000000..bb49d1f --- /dev/null +++ b/src/tests.ts @@ -0,0 +1,70 @@ +import { expresses, extendsAndExpresses, trait } from "." + + +const Identifiable = () => + trait(Parent => { + abstract class Identifiable extends Parent { + abstract readonly id: ID + + equals(el: Identifiable) { + return this.id === el.id + } + + constructor(...args: any[]) { + super(...args) + console.log("Identified constructor") + } + } + + return Identifiable + }) + +const ImplementsIdentifiable = (defaultID: ID) => + trait(Parent => { + abstract class ImplementsIdentifiable extends extendsAndExpresses( + Parent, + [Identifiable()], + ) { + id: ID = defaultID + + constructor(...args: any[]) { + super(...args) + console.log("ImplementsIdentifiable constructor") + } + } + + return ImplementsIdentifiable + }) + + +const Permissible = trait(Parent => { + abstract class Permissible extends Parent { + static readonly defaultPermissions: string[] = [] + permissions: string[] = [] + + constructor(...args: any[]) { + super(...args) + console.log("Permissible constructor") + } + } + + return Permissible +}) + + +const UserProto = expresses( + // Identifiable(), + ImplementsIdentifiable(0n), + Permissible, +) + +class User extends UserProto { + constructor(id: bigint) { + super() + this.id = id + } +} + +const user1 = new User(1n) +console.log(user1) +console.log(user1.equals(user1)) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5470793 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + // "allowImportingTsExtensions": true, + // "noEmit": true, + "declaration": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" + ] + }, + "include": ["src"] +}