diff --git a/.github/workflows/lest.yml b/.github/workflows/lest.yml index 26a3c66..dfdbe1e 100644 --- a/.github/workflows/lest.yml +++ b/.github/workflows/lest.yml @@ -2,9 +2,6 @@ name: Lest CI on: workflow_call: - push: - branches-ignore: - - master jobs: lint: @@ -34,7 +31,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - luaVersion: ["5.1.5", "5.2.4", "5.3.6", "5.4.4", "luajit-openresty", "luajit-2.0.5", "luajit-2.1.0-beta3"] + luaVersion: ["5.1.5", "5.2.4", "5.3.6", "5.4.4", "luajit-2.0.5", "luajit-2.1.0-beta3"] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/luals_annotations_release.yml b/.github/workflows/luals_annotations_release.yml new file mode 100644 index 0000000..deda770 --- /dev/null +++ b/.github/workflows/luals_annotations_release.yml @@ -0,0 +1,40 @@ +name: LuaLS Annotations +on: + push: + branches: + - master +jobs: + deploy-annotations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: 18 + + - run: npm install + + - name: Build LuaLS-annotations + run: npm run build:luals-annotations + + - name: Generate annotations + run: npm run annotate:luals -- /tmp/annotations.lua + + - name: Checkout to new branch + run: | + git checkout luals-annotations || git checkout --orphan luals-annotations && git rm --cached -r . + + - name: Pull changes + run: git pull origin luals-annotations + + - name: Copy annotations + run: cp /tmp/annotations.lua . + + - name: Push annotations + uses: EndBug/add-and-commit@v9 + with: + add: "annotations.lua" + default_author: github_actions + message: "update annotations" + new_branch: luals-annotations diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..ab062d3 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,9 @@ +name: Pull request created or updated + +on: pull_request + +jobs: + lest: + uses: "./.github/workflows/lest.yml" + # Only run on forks to avoid duplicating the "push" workflow + if: ${{ github.event.pull_request.head.repo.fork }} diff --git a/.github/workflows/push_feature.yml b/.github/workflows/push_feature.yml new file mode 100644 index 0000000..e6bedce --- /dev/null +++ b/.github/workflows/push_feature.yml @@ -0,0 +1,10 @@ +name: Push to feature branch + +on: + push: + branches-ignore: + - master + +jobs: + lest: + uses: ./.github/workflows/lest.yml diff --git a/.gitignore b/.gitignore index f53be27..064b6fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ packages/**/dist node_modules +packages/**/coverage .idea/discord.xml *.tsbuildinfo diff --git a/package-lock.json b/package-lock.json index 7af7a2a..ef70886 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "workspaces": [ "packages/lest", "packages/docs", - "packages/site" + "packages/site", + "packages/luals-annotations" ], "devDependencies": { "@commitlint/cli": "^17.5.1", @@ -2028,6 +2029,18 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@blakeembrey/deque": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz", + "integrity": "sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg==", + "dev": true + }, + "node_modules/@blakeembrey/template": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@blakeembrey/template/-/template-1.1.0.tgz", + "integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==", + "dev": true + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3581,6 +3594,10 @@ "resolved": "packages/docs", "link": true }, + "node_modules/@lest/luals-annotations": { + "resolved": "packages/luals-annotations", + "link": true + }, "node_modules/@lest/site": { "resolved": "packages/site", "link": true @@ -11254,6 +11271,30 @@ "wrappy": "1" } }, + "node_modules/onchange": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/onchange/-/onchange-7.1.0.tgz", + "integrity": "sha512-ZJcqsPiWUAUpvmnJri5TPBooqJOPmC0ttN65juhN15Q8xA+Nbg3BaxBHXQ45EistKKlKElb0edmbPWnKSBkvMg==", + "dev": true, + "dependencies": { + "@blakeembrey/deque": "^1.0.5", + "@blakeembrey/template": "^1.0.0", + "arg": "^4.1.3", + "chokidar": "^3.3.1", + "cross-spawn": "^7.0.1", + "ignore": "^5.1.4", + "tree-kill": "^1.2.2" + }, + "bin": { + "onchange": "dist/bin.js" + } + }, + "node_modules/onchange/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -14786,6 +14827,15 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/trim": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.3.tgz", @@ -16416,6 +16466,20 @@ "typescript": "^5.1.6" } }, + "packages/luals-annotations": { + "name": "@lest/luals-annotations", + "dependencies": { + "@lest/docs": "*" + }, + "devDependencies": { + "@types/jest": "^29.5.2", + "jest": "^29.5.0", + "onchange": "^7.1.0", + "ts-jest": "^29.1.0", + "ts-node": "^10.9.1", + "typescript": "^5.1.3" + } + }, "packages/site": { "name": "@lest/site", "dependencies": { diff --git a/package.json b/package.json index ad8eee8..31c62cf 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "format:site": "prettier --check packages/site", "format:docs": "prettier --check packages/docs", "build:lest": "npm run build --workspace=packages/lest", + "build:luals-annotations": "npm run build --workspace=packages/luals-annotations", + "test:luals-annotations": "npm run test --workspace=packages/luals-annotations", + "annotate:luals": "npm run start --workspace=packages/luals-annotations", "build:docs": "npm run build --workspace=packages/docs", "build:site": "npm run build:docs && npm run build --workspace=packages/site", "test:lest": "npm run test --workspace=packages/lest", @@ -17,7 +20,8 @@ "workspaces": [ "packages/lest", "packages/docs", - "packages/site" + "packages/site", + "packages/luals-annotations" ], "devDependencies": { "@commitlint/cli": "^17.5.1", @@ -30,6 +34,5 @@ "trim": "0.0.3", "got": ">=11.8.5", "semver": ">=7.5.2" - } } diff --git a/packages/docs/package.json b/packages/docs/package.json index 11c23bf..58bc3fd 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -2,8 +2,14 @@ "name": "@lest/docs", "private": true, "description": "API documentation in JSON form.", - "main": "dist/index", - "types": "dist/index", + "main": "dist/src/index", + "types": "dist/src/index", + "exports": { + ".": "./dist/src/index.js", + "./functions": "./dist/src/functions/index.js", + "./types": "./dist/src/types/index.js", + "./matchers": "./dist/src/matchers/index.js" + }, "scripts": { "build": "tsc" } diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json index 6ddc33f..07a1043 100644 --- a/packages/docs/tsconfig.json +++ b/packages/docs/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist" + "outDir": "./dist", + "composite": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.json"] } diff --git a/packages/luals-annotations/jest.config.js b/packages/luals-annotations/jest.config.js new file mode 100644 index 0000000..ae0beab --- /dev/null +++ b/packages/luals-annotations/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/*.test.ts"], + collectCoverage: true, + collectCoverageFrom: ["src/**/{!(index),}.ts"], + coverageThreshold: { + global: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + }, +}; diff --git a/packages/luals-annotations/package.json b/packages/luals-annotations/package.json new file mode 100644 index 0000000..19ccc9a --- /dev/null +++ b/packages/luals-annotations/package.json @@ -0,0 +1,23 @@ +{ + "name": "@lest/luals-annotations", + "description": "Package to automatically create LuaLS annotations for Lest", + "main": "dist/index", + "scripts": { + "start:ts-node": "ts-node src/index.ts", + "start": "node dist/index.js", + "dev": "npm run --debug start:ts-node && onchange \"src/**/*.ts\" -- npm run --debug start:ts-node", + "build": "tsc --build tsconfig.build.json", + "test": "jest" + }, + "devDependencies": { + "@types/jest": "^29.5.2", + "jest": "^29.5.0", + "onchange": "^7.1.0", + "ts-jest": "^29.1.0", + "ts-node": "^10.9.1", + "typescript": "^5.1.3" + }, + "dependencies": { + "@lest/docs": "*" + } +} diff --git a/packages/luals-annotations/src/annotationBuilder.test.ts b/packages/luals-annotations/src/annotationBuilder.test.ts new file mode 100644 index 0000000..b77df87 --- /dev/null +++ b/packages/luals-annotations/src/annotationBuilder.test.ts @@ -0,0 +1,347 @@ +import AnnotationBuilder, { ClassBuilder } from "./annotationBuilder"; + +describe("annotationBuilder", () => { + it("should generate correct function annotations", () => { + // Arrange + const document = new AnnotationBuilder(); + document.addFunction({ + name: "test", + description: "test description", + parameters: [ + { + name: "a", + type: "string", + }, + ], + returns: [ + { + name: "b", + type: "number", + }, + ], + }); + + const expectedElements = [ + "---@param a string", + "---@return number b", + "function test(a) end", + "test description", + ]; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + }); + + it("should generate correct class annotations", () => { + // Arrange + const document = new AnnotationBuilder(); + const cls = new ClassBuilder({ + name: "test", + description: "my class", + }); + + cls.addField({ + name: "test", + type: "string", + description: "test class description", + }); + + cls.addDeclaration(); + cls.addFunction({ + name: "test", + description: "test function description", + parameters: [ + { + name: "a", + type: "string", + }, + ], + returns: [ + { + name: "b", + type: "number", + }, + ], + }); + + document.addClass(cls); + + const expectedElements = [ + "---@class test", + "my class", + "---@field test string", + "test class description", + "test = {}", + "---@param a string", + "---@return number b", + "function test:test(a) end", + "test function description", + ]; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + }); + + it("should handle multi-line descriptions", () => { + // Arrange + const document = new AnnotationBuilder(); + document.addFunction({ + name: "test", + description: ["Hello!", "World!"], + parameters: [ + { + name: "a", + type: "string", + }, + ], + }); + + const expectedElements = ["---@param a string", "--- Hello!", "--- World!", "function test(a) end"]; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + }); + + it("should handle multi-line descriptions for fields", () => { + // Arrange + const document = new AnnotationBuilder(); + const cls = new ClassBuilder({ + name: "test", + description: "my class", + }); + + cls.addField({ + name: "test", + type: "string", + description: ["Hello!", "World!"], + }); + + cls.addDeclaration(); + document.addClass(cls); + + const expectedElements = ["---@field test string", "--- Hello!", "--- World!"]; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + }); + + it("should handle multi-line descriptions for classes", () => { + // Arrange + const document = new AnnotationBuilder(); + const cls = new ClassBuilder({ + name: "test", + description: ["Hello!", "World!"], + }); + + cls.addDeclaration(); + document.addClass(cls); + + const expectedElements = ["---@class test", "--- Hello!", "--- World!", "test = {}"]; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + }); + + it("should handle single line descriptions", () => { + // Arrange + const document = new AnnotationBuilder(); + document.addFunction({ + name: "test", + description: "Hello!", + parameters: [], + returns: [], + }); + + const expectedElement = "--- Hello!"; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expect(generatedAnnotations).toContain(expectedElement); + }); + + it("should handle undefined class descriptions", () => { + // Arrange + const document = new AnnotationBuilder(); + const cls = new ClassBuilder({ + name: "test", + description: undefined, + }); + + cls.addDeclaration(); + document.addClass(cls); + + const unexpectedElement = "--- undefined"; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expect(generatedAnnotations).not.toContain(unexpectedElement); + }); + + it("should handle undefined function descriptions", () => { + // Arrange + const document = new AnnotationBuilder(); + document.addFunction({ + name: "test", + description: undefined, + parameters: [], + returns: [], + }); + + const unexpectedElement = "--- undefined"; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expect(generatedAnnotations).not.toContain(unexpectedElement); + }); + + it("should handle static methods on classes", () => { + // Arrange + const document = new AnnotationBuilder(); + const cls = new ClassBuilder({ + name: "test", + description: "static class", + }); + + cls.addDeclaration(); + cls.addFunction( + { + name: "test", + description: "test function description", + parameters: [ + { + name: "a", + type: "string", + }, + ], + }, + true + ); + + document.addClass(cls); + + const expectedElement = "function test.test(a) end"; + // Act + const generatedAnnotations = document.build(); + // Assert + expect(generatedAnnotations).toContain(expectedElement); + }); + + it("should generate aliases of a function", () => { + // Arrange + const document = new AnnotationBuilder(); + document.addFunction({ + name: "test", + description: "test function description", + aliases: ["test2", "test3"], + parameters: [ + { + name: "a", + type: "string", + }, + ], + }); + + const expectedElements = ["function test(a) end", "function test2(a) end", "function test3(a) end"]; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + }); + + it("should handle functions with no parameters", () => { + // Arrange + const document = new AnnotationBuilder(); + + document.addFunction({ + name: "test", + description: "test function description", + returns: [ + { + name: "b", + type: "number", + }, + ], + }); + + const expectedElements = ["---@return number b", "function test() end"]; + const unexpectedElement = "---@param"; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + expect(generatedAnnotations).not.toContain(unexpectedElement); + }); + + it("should handle functions with optional parameters", () => { + // Arrange + const document = new AnnotationBuilder(); + + document.addFunction({ + name: "test", + description: "test function description", + parameters: [ + { + name: "a", + type: "string", + optional: true, + }, + ], + }); + + const expectedElements = ["---@param a? string", "function test(a) end"]; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + }); + + it("should handle functions with unnamed return values", () => { + // Arrange + const document = new AnnotationBuilder(); + + document.addFunction({ + name: "test", + description: "test function description", + returns: [ + { + type: "number", + }, + ], + }); + + const expectedElements = ["---@return number", "function test() end"]; + + // Act + const generatedAnnotations = document.build(); + + // Assert + expectedElements.forEach((element) => expect(generatedAnnotations).toContain(element)); + }); +}); diff --git a/packages/luals-annotations/src/annotationBuilder.ts b/packages/luals-annotations/src/annotationBuilder.ts new file mode 100644 index 0000000..9eeb17a --- /dev/null +++ b/packages/luals-annotations/src/annotationBuilder.ts @@ -0,0 +1,121 @@ +import luaLSType, { formatListProperty } from "./luaLSType"; +import { Function, Property } from "@lest/docs"; + +type FunctionRenderOptions = { + staticMethod?: boolean; + className?: string; +}; + +function renderParameterAnnotations(parameters: Property[]): string[] { + return parameters.map((param) => `---@param ${formatListProperty(param)}`); +} + +function renderReturnAnnotations(returns: Property[]): string[] { + return returns.map((ret) => `---@return ${luaLSType(ret)} ${ret.name ?? ""}`); +} + +function renderFunctionAliases( + { name, parameters = [], aliases = [] }: Function, + staticMethod: boolean, + className: string +): string[] { + const functionPrefix = className ? `${className}${staticMethod ? "." : ":"}` : ""; + + const functionNames = [name, ...(aliases ?? [])]; + const parameterNames = parameters.map((property: Property) => property.name); + + return functionNames.map((name) => `function ${functionPrefix}${name}(${parameterNames.join(", ")}) end`); +} + +function renderDescription(description: string | string[]): string[] { + const descriptionLines = Array.isArray(description) ? description : [description]; + return descriptionLines.map((line) => `--- ${line}`); +} + +function renderFunctionDeclaration( + { parameters = [], returns = [], description = [], ...func }: Function, + { staticMethod = false, className = "" }: FunctionRenderOptions = {} +): string { + const descriptionLines = renderDescription(description); + const paramList = renderParameterAnnotations(parameters); + const returnList = renderReturnAnnotations(returns); + + const functionSignatures = renderFunctionAliases( + { parameters, returns, description, ...func }, + staticMethod, + className + ); + + return functionSignatures + .map((signature) => [...descriptionLines, ...paramList, ...returnList, signature].join("\n")) + .join("\n"); +} + +export class DocumentBuilder { + lines: string[] = []; + + add(line: string) { + this.lines.push(line); + } + + build(): string { + return this.lines.join("\n"); + } +} + +type ClassOptions = { + name: string; + description?: string | string[]; +}; + +export class ClassBuilder extends DocumentBuilder { + name: string; + description?: string | string[]; + + constructor(options: ClassOptions) { + super(); + + this.name = options.name; + this.description = options.description; + + this.addClassAnnotations(); + } + + addClassAnnotations() { + this.add(`---@class ${this.name}`); + this.addDescription(this.description); + } + + addDescription(description: string[] | string | undefined) { + const descriptionLines = Array.isArray(description) ? description : [description]; + descriptionLines.forEach((line) => line && this.add(`--- ${line}`)); + } + + addFunction(func: Function, staticMethod: boolean = false) { + this.add(renderFunctionDeclaration(func, { staticMethod: staticMethod, className: this.name })); + } + + addField(property: Property) { + this.addDescription(property.description); + this.add(`---@field ${formatListProperty(property)}`); + } + + addDeclaration() { + this.add(`${this.name} = {}`); + } +} + +export default class AnnotationBuilder extends DocumentBuilder { + constructor() { + super(); + this.add("---@meta"); + } + + addFunction(func: Function) { + this.add(renderFunctionDeclaration(func)); + } + + addClass(cls: ClassBuilder) { + this.lines = [...this.lines, ...cls.lines]; + } +} diff --git a/packages/luals-annotations/src/index.ts b/packages/luals-annotations/src/index.ts new file mode 100644 index 0000000..97f6a4c --- /dev/null +++ b/packages/luals-annotations/src/index.ts @@ -0,0 +1,69 @@ +import * as functions from "@lest/docs/functions"; +import * as classes from "@lest/docs/types"; +import * as matchers from "@lest/docs/matchers"; +import { Class, Function, isFunctionProperty } from "@lest/docs"; + +import * as fs from "fs"; +import AnnotationBuilder, { ClassBuilder } from "./annotationBuilder"; + +const functionDocs = Object.values(functions); +const classDocs = Object.values(classes); +const matcherDocs = Object.values(matchers); + +const document = new AnnotationBuilder(); + +function generateClass({ name, description, fields = [] }: Class) { + const cls = new ClassBuilder({ + name, + description, + }); + + const classMethods = fields.filter((method) => isFunctionProperty(method)); + const classFields = fields.filter((property) => !isFunctionProperty(property)); + + classFields.forEach((field) => cls.addField(field)); + cls.addDeclaration(); + classMethods.forEach((method) => cls.addFunction(method as Function)); + document.addClass(cls); +} + +function generateMatcherClass({ inverse }: { inverse: boolean }) { + const name = inverse ? "lest.InverseMatchers" : "lest.Matchers"; + const description = inverse ? "Inverse matchers for expect()" : "Matchers for expect()"; + + const matchersClass = new ClassBuilder({ + name, + description, + }); + + if (!inverse) { + matchersClass.addField({ + name: "never", + type: "lest.InverseMatchers", + description: "Inverse matchers", + }); + } + + matchersClass.addDeclaration(); + matcherDocs.forEach((matcher) => matchersClass.addFunction(matcher, true)); + document.addClass(matchersClass); +} + +classDocs.forEach(generateClass); +functionDocs.forEach((func) => document.addFunction(func)); +generateMatcherClass({ inverse: false }); +generateMatcherClass({ inverse: true }); + +// This environment variable is set by NPM when you pass --debug to it +if (process.env["npm_config_debug"]) { + console.log(document.build()); + process.exit(0); +} + +const targetFilePath = process.argv[2]; +if (!targetFilePath) { + console.error("No target file path provided"); + process.exit(1); +} + +fs.writeFileSync(targetFilePath, document.build()); diff --git a/packages/luals-annotations/src/luaLSType.test.ts b/packages/luals-annotations/src/luaLSType.test.ts new file mode 100644 index 0000000..fa86459 --- /dev/null +++ b/packages/luals-annotations/src/luaLSType.test.ts @@ -0,0 +1,210 @@ +import luaLSType from "./luaLSType"; +import { ArrayProperty, TableProperty, FunctionProperty, Property } from "@lest/docs"; + +describe("luaLSType", () => { + it("should handle untyped arrays", () => { + // Arrange + const testProperty: ArrayProperty = { + name: "test", + type: "array", + items: "any", + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("any[]"); + }); + + it("should handle typed arrays", () => { + // Arrange + const testProperty: ArrayProperty = { + name: "test", + type: "array", + items: "lest.MockResult", + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("lest.MockResult[]"); + }); + + it("should handle untyped arrays of arrays", () => { + // Arrange + const testProperty: ArrayProperty = { + name: "test", + type: "array", + items: { + type: "array", + items: "any", + }, + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("any[][]"); + }); + + it("should handle specified function types", () => { + // Arrange + const testProperty: FunctionProperty = { + name: "test", + type: "function", + parameters: [ + { + name: "a", + type: "string", + }, + ], + returns: [ + { + name: "b", + type: "number", + }, + ], + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("fun(a:string):number"); + }); + + it("should handle unspecified function types", () => { + // Arrange + const testProperty: Property = { + name: "test", + type: "function", + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("fun()"); + }); + + it("should handle functions with no returns", () => { + // Arrange + const testProperty: FunctionProperty = { + name: "test", + type: "function", + parameters: [ + { + name: "a", + type: "string", + }, + ], + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("fun(a:string)"); + }); + + it("should handle functions with optional parameters", () => { + // Arrange + const testProperty: FunctionProperty = { + name: "test", + type: "function", + parameters: [ + { + name: "a", + type: "string", + }, + { + name: "b", + type: "string", + optional: true, + }, + ], + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("fun(a:string,b?:string)"); + }); + + it("should handle multiple string literal types", () => { + // These are actually valid in LuaLS. So we don't need to do anything special. + // Arrange + const testProperty: Property = { + name: "test", + type: '"test" | "test2"', + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual('"test" | "test2"'); + }); + + it("should handle tables", () => { + // Arrange + const testProperty: TableProperty = { + name: "test", + type: "table", + fields: [ + { + name: "foo", + type: "string", + }, + { + name: "bar", + type: "number", + }, + { + name: "baz", + type: "boolean", + }, + { + name: "qux", + type: "function", + parameters: [ + { + name: "a", + type: "string", + }, + ], + returns: [ + { + name: "b", + type: "number", + }, + ], + } as FunctionProperty, + ], + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("{foo:string,bar:number,baz:boolean,qux:fun(a:string):number}"); + }); + + it("should handle generic table types", () => { + // Arrange + const testProperty: Property = { + name: "test", + type: "table", + }; + + // Act + const generatedType = luaLSType(testProperty); + + // Assert + expect(generatedType).toEqual("table"); + }); +}); diff --git a/packages/luals-annotations/src/luaLSType.ts b/packages/luals-annotations/src/luaLSType.ts new file mode 100644 index 0000000..2c54ea9 --- /dev/null +++ b/packages/luals-annotations/src/luaLSType.ts @@ -0,0 +1,60 @@ +import { + FunctionProperty, + ArrayProperty, + TableProperty, + isArrayProperty, + isFunctionProperty, + isTableProperty, + Property, +} from "@lest/docs"; + +export function formatListProperty(property: Property): string { + return formatProperty(property, " "); +} + +function formatProperty({ name, optional, ...props }: Property, typeSeparator = ":"): string { + return `${name}${optional ? "?" : ""}${typeSeparator}${luaLSType({ name, optional, ...props })}`; +} + +function convertFunctionProperty({ parameters = [], returns = [] }: FunctionProperty): string { + const paramList = parameters.map((param) => formatProperty(param)); + const returnList = returns.map((ret) => luaLSType(ret)); + + if (returnList.length === 0) { + return `fun(${paramList})`; + } + + return `fun(${paramList}):${returnList}`; +} + +function convertArrayProperty(property: ArrayProperty) { + if (typeof property.items === "string") { + return `${property.items}[]`; + } else { + return `${luaLSType(property.items)}[]`; + } +} + +function convertTableProperty(property: TableProperty) { + if (!property.fields) { + return "table"; + } + + return `{${property.fields.map((field) => formatProperty(field)).join(",")}}`; +} + +export default function luaLSType(docType: Property): string { + if (isFunctionProperty(docType)) { + return convertFunctionProperty(docType); + } + + if (isArrayProperty(docType)) { + return convertArrayProperty(docType); + } + + if (isTableProperty(docType)) { + return convertTableProperty(docType); + } + + return docType.type; +} diff --git a/packages/luals-annotations/tsconfig.build.json b/packages/luals-annotations/tsconfig.build.json new file mode 100644 index 0000000..5c936df --- /dev/null +++ b/packages/luals-annotations/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": ["./tsconfig.json"], + "exclude": ["src/**/*.test.ts"], + "references": [{ "path": "../docs/tsconfig.json" }] +} diff --git a/packages/luals-annotations/tsconfig.json b/packages/luals-annotations/tsconfig.json new file mode 100644 index 0000000..bcf8312 --- /dev/null +++ b/packages/luals-annotations/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["./src/**/*"], + "references": [{ "path": "../docs/tsconfig.json" }] +}