diff --git a/.vscode/launch.json b/.vscode/launch.json index cfc2114..3cc2dc0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,6 +6,12 @@ "name": "Run Wollok CLI tests", "request": "launch", "type": "node-terminal" + }, + { + "command": "npm run test:file ${file}", + "name": "Run a single test file", + "request": "launch", + "type": "node-terminal" } ] } diff --git a/examples/init-examples/existing-folder/.gitignore b/examples/init-examples/existing-folder/.gitignore index 9e95028..9e734a5 100644 --- a/examples/init-examples/existing-folder/.gitignore +++ b/examples/init-examples/existing-folder/.gitignore @@ -3,4 +3,8 @@ .history # Wollok Log +log *.log + +# Dependencies +node_modules diff --git a/examples/user-natives/myNativesFolder/myPackage/myInnerFile.js b/examples/user-natives/myNativesFolder/myPackage/myInnerFile.js new file mode 100644 index 0000000..c4f52e9 --- /dev/null +++ b/examples/user-natives/myNativesFolder/myPackage/myInnerFile.js @@ -0,0 +1,7 @@ +const packageModel = { + *nativeTwo(self) { + return yield* this.reify(2) + }, +} + +module.exports = { packageModel }; diff --git a/examples/user-natives/myNativesFolder/rootFile.ts b/examples/user-natives/myNativesFolder/rootFile.ts new file mode 100644 index 0000000..d079f68 --- /dev/null +++ b/examples/user-natives/myNativesFolder/rootFile.ts @@ -0,0 +1,7 @@ +const myModel = { + *nativeOne(this: any, _self: any): any { + return yield* this.reify(1) + }, +} + +export { myModel } \ No newline at end of file diff --git a/examples/user-natives/myPackage/myInnerFile.wlk b/examples/user-natives/myPackage/myInnerFile.wlk new file mode 100644 index 0000000..c24fd16 --- /dev/null +++ b/examples/user-natives/myPackage/myInnerFile.wlk @@ -0,0 +1,3 @@ +object packageModel { + method nativeTwo() native +} \ No newline at end of file diff --git a/examples/user-natives/myProgram.wpgm b/examples/user-natives/myProgram.wpgm new file mode 100644 index 0000000..df11c19 --- /dev/null +++ b/examples/user-natives/myProgram.wpgm @@ -0,0 +1,8 @@ +import myPackage.myInnerFile.packageModel +import rootFile.myModel + +program myProgram { + + console.println(myModel.nativeOne()) + console.println(packageModel.nativeTwo()) +} diff --git a/examples/user-natives/package.json b/examples/user-natives/package.json new file mode 100644 index 0000000..ced106f --- /dev/null +++ b/examples/user-natives/package.json @@ -0,0 +1,9 @@ +{ + "name": "user-natives", + "version": "1.0.0", + "resourceFolder": "assets", + "wollokVersion": "4.0.0", + "author": "leo", + "license": "ISC", + "natives": "myNativesFolder" +} diff --git a/examples/user-natives/rootFile.wlk b/examples/user-natives/rootFile.wlk new file mode 100644 index 0000000..1ec23d5 --- /dev/null +++ b/examples/user-natives/rootFile.wlk @@ -0,0 +1,3 @@ +object myModel { + method nativeOne() native +} diff --git a/examples/user-natives/userNatives.wtest b/examples/user-natives/userNatives.wtest new file mode 100644 index 0000000..3868a6c --- /dev/null +++ b/examples/user-natives/userNatives.wtest @@ -0,0 +1,7 @@ +import rootFile.myModel +import myPackage.myInnerFile.packageModel + +test "call a native method used by user" { + assert.equals(1, myModel.nativeOne()) + assert.equals(2, packageModel.nativeTwo()) +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7f6a7bf..77f6da2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wollok-ts-cli", - "version": "0.2.11", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wollok-ts-cli", - "version": "0.2.11", + "version": "0.3.0", "license": "MIT", "dependencies": { "@badisi/latest-version": "^7.0.10", @@ -23,8 +23,9 @@ "pkg": "^5.8.1", "print-message": "^3.0.1", "socket.io": "^4.5.1", + "typescript": "~5.5.1", "winston": "^3.11.0", - "wollok-ts": "^4.1.9", + "wollok-ts": "file:../wollok-ts", "wollok-web-tools": "^1.1.7" }, "bin": { @@ -52,8 +53,42 @@ "shx": "^0.3.4", "sinon": "^17.0.1", "socket.io-client": "^4.7.5", - "ts-node": "10.9.1", - "typescript": "~5.5.1" + "ts-node": "10.9.1" + } + }, + "../wollok-ts": { + "version": "4.1.10", + "license": "MIT", + "dependencies": { + "@types/parsimmon": "^1.10.8", + "parsimmon": "^1.18.1", + "prettier-printer": "^1.1.4", + "unraw": "^3.0.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/chai": "^4.3.9", + "@types/mocha": "^10.0.3", + "@types/node": "^18.14.1", + "@types/sinon": "^10.0.20", + "@types/sinon-chai": "^3.2.11", + "@types/uuid": "^9.0.6", + "@types/yargs": "^17.0.22", + "@typescript-eslint/eslint-plugin": "^5.53.0", + "@typescript-eslint/parser": "^5.53.0", + "chai": "^4.3.7", + "chalk": "^5.2.0", + "dedent": "^1.5.1", + "eslint": "^8.35.0", + "globby": "^11.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "simple-git": "^3.20.0", + "sinon": "17.0.0", + "sinon-chai": "^3.7.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5", + "yargs": "^17.7.2" } }, "node_modules/@ampproject/remapping": { @@ -1176,12 +1211,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/parsimmon": { - "version": "1.10.9", - "resolved": "https://registry.npmjs.org/@types/parsimmon/-/parsimmon-1.10.9.tgz", - "integrity": "sha512-O2M2x1w+m7gWLen8i5DOy6tWRnbRcsW6Pke3j3HAsJUrPb4g0MgjksIUm2aqUtCYxy7Qjr3CzjjwQBzhiGn46A==", - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", @@ -3866,12 +3895,6 @@ "node": ">=8" } }, - "node_modules/infestines": { - "version": "0.4.11", - "resolved": "https://registry.npmjs.org/infestines/-/infestines-0.4.11.tgz", - "integrity": "sha512-09nHagZLOYUaXKHqdV+nxEaYaD0hRlKyhQMhgTMwfbvWpMkowXf4XLZzAkLq6Y90wZ7Wqm6aMoL2trBsNNKGeg==", - "license": "MIT" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5411,31 +5434,6 @@ "node": ">= 0.8" } }, - "node_modules/parsimmon": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/parsimmon/-/parsimmon-1.18.1.tgz", - "integrity": "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw==", - "license": "MIT" - }, - "node_modules/partial.lenses": { - "version": "14.17.0", - "resolved": "https://registry.npmjs.org/partial.lenses/-/partial.lenses-14.17.0.tgz", - "integrity": "sha512-Iq+wDw5b5iwmn7b9MO+//buOqF0YoAC2Hsj+HoeG88BGeT3NkL/YwHNGDrySyX7xZSqj0LFEWwfgGSj4cSFmXQ==", - "license": "MIT", - "dependencies": { - "infestines": "^0.4.11" - } - }, - "node_modules/partial.lenses.validation": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/partial.lenses.validation/-/partial.lenses.validation-2.0.0.tgz", - "integrity": "sha512-NBZhmrunQWc4Ih5pJGYPHCEJfjIITPEkYn0Z6YB8qmW0iN4ZeqN0eZTWAPAH8MxjsEGx3G+8xnTMsEHbzy0k/A==", - "license": "MIT", - "dependencies": { - "infestines": "^0.4.10", - "partial.lenses": "^14.0.0" - } - }, "node_modules/path": { "version": "0.12.7", "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", @@ -5735,16 +5733,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier-printer": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/prettier-printer/-/prettier-printer-1.1.4.tgz", - "integrity": "sha512-gQIWF7PKVknRcMoZ4Lsqj2icc97W+Q9JTa7xQgNem07yZFzOimUPq50N5oRfKkSRBZT8QqrVW1IBEMBPWqjBgQ==", - "license": "MIT", - "dependencies": { - "infestines": "^0.4.11", - "partial.lenses.validation": "^2.0.0" - } - }, "node_modules/print-message": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/print-message/-/print-message-3.0.1.tgz", @@ -7098,7 +7086,6 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7151,12 +7138,6 @@ "node": ">= 0.8" } }, - "node_modules/unraw": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", - "integrity": "sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==", - "license": "MIT" - }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", @@ -7393,30 +7374,8 @@ } }, "node_modules/wollok-ts": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/wollok-ts/-/wollok-ts-4.1.9.tgz", - "integrity": "sha512-mgbaqdebQrM+RQMhtgTAtHP9m/8u6JPRAUYV53xqjBaMZh1wQ4xrQb8e6wmJJoXs8XRWdUgTZXLnJLvN1HyA2Q==", - "license": "MIT", - "dependencies": { - "@types/parsimmon": "^1.10.8", - "parsimmon": "^1.18.1", - "prettier-printer": "^1.1.4", - "unraw": "^3.0.0", - "uuid": "^9.0.1" - } - }, - "node_modules/wollok-ts/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } + "resolved": "../wollok-ts", + "link": true }, "node_modules/wollok-web-tools": { "version": "1.1.7", diff --git a/package.json b/package.json index 0ded75c..caeda92 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "lint": "eslint . ", "lint:fix": "eslint . --fix", "test:unit": "mocha --parallel -r ts-node/register/transpile-only test/**/*.test.ts --timeout 5000", + "test:file": "mocha --parallel -r ts-node/register/transpile-only", "build:tools": "shx cp ./node_modules/wollok-web-tools/dist/web/game-index.js ./public/game/lib && shx cp ./node_modules/wollok-web-tools/dist/dynamicDiagram/diagram-index.js ./public/diagram", "build": "shx rm -rf build && shx mkdir ./build && shx cp -r ./public ./build/public && tsc -p ./tsconfig.build.json", "watch": "npm run build -- -w", @@ -67,7 +68,8 @@ "socket.io": "^4.5.1", "winston": "^3.11.0", "wollok-ts": "^4.1.9", - "wollok-web-tools": "^1.1.7" + "wollok-web-tools": "^1.1.7", + "typescript": "~5.5.1" }, "devDependencies": { "@stylistic/eslint-plugin-ts": "^2.8.0", @@ -91,7 +93,6 @@ "shx": "^0.3.4", "sinon": "^17.0.1", "socket.io-client": "^4.7.5", - "ts-node": "10.9.1", - "typescript": "~5.5.1" + "ts-node": "10.9.1" } } diff --git a/src/commands/dependencies.ts b/src/commands/dependencies.ts new file mode 100644 index 0000000..c54c216 --- /dev/null +++ b/src/commands/dependencies.ts @@ -0,0 +1,28 @@ +import { execSync } from 'child_process' +import path from 'path' +import logger from 'loglevel' + +export type Options = { + project: string + verbose: boolean +} + +const executeNpmCommand = (command: string, project: string, verbose: boolean): void => { + const fullCommand = `npm ${command}` + if (verbose) { + logger.info(`Executing in ${project}: ${fullCommand}`) + } + execSync(fullCommand, { cwd: path.resolve(project), stdio: 'inherit' }) +} + +export const addDependency = (pkg: string, { project, verbose }: Options): void => { + executeNpmCommand(`install ${pkg}`, project, verbose) +} + +export const removeDependency = (pkg: string, { project, verbose }: Options): void => { + executeNpmCommand(`uninstall ${pkg}`, project, verbose) +} + +export const synchronizeDependencies = ({ project, verbose }: Options): void => { + executeNpmCommand('install', project, verbose) +} \ No newline at end of file diff --git a/src/commands/init.ts b/src/commands/init.ts index b8afe6a..a3c122e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -13,11 +13,13 @@ export type Options = { noTest: boolean, noCI: boolean, game: boolean, - noGit: boolean + noGit: boolean, + natives?: string } -export default function (folder: string | undefined, { project: _project, name, noTest = false, noCI = false, game = false, noGit = false }: Options): void { +export default function (folder: string | undefined, { project: _project, name, noTest = false, noCI = false, game = false, noGit = false, natives = undefined }: Options): void { const project = join(_project, folder ?? '') + const nativesFolder = join(project, natives ?? '') // Initialization if (existsSync(join(project, 'package.json'))) { @@ -28,6 +30,7 @@ export default function (folder: string | undefined, { project: _project, name, // Creating folders createFolderIfNotExists(project) + createFolderIfNotExists(nativesFolder) createFolderIfNotExists(join(project, '.github')) createFolderIfNotExists(join(project, '.github', 'workflows')) if (game) { @@ -52,7 +55,7 @@ export default function (folder: string | undefined, { project: _project, name, } logger.info('Creating package.json') - writeFileSync(join(project, 'package.json'), packageJsonDefinition(project, game)) + writeFileSync(join(project, 'package.json'), packageJsonDefinition(project, game, natives )) if (!noCI) { logger.info('Creating CI files') @@ -130,16 +133,16 @@ program PepitaGame { } ` -const packageJsonDefinition = (projectName: string, game: boolean) => `{ +const packageJsonDefinition = (projectName: string, game: boolean, natives?: string) => `{ "name": "${basename(projectName)}", "version": "1.0.0", ${game ? assetsConfiguration() : ''}"wollokVersion": "4.0.0", - "author": "${userInfo().username}", + "author": "${userInfo().username}",${nativesConfiguration(natives)} "license": "ISC" } ` - const assetsConfiguration = () => `"resourceFolder": "assets",${ENTER} ` +const nativesConfiguration = (natives?: string) => natives ? `${ENTER} "natives": "${natives}",` : '' const ymlForCI = `name: build @@ -170,5 +173,9 @@ const gitignore = ` .history # Wollok Log +log *.log + +# Dependencies +node_modules ` \ No newline at end of file diff --git a/src/commands/repl.ts b/src/commands/repl.ts index 6dff32e..4911dbe 100644 --- a/src/commands/repl.ts +++ b/src/commands/repl.ts @@ -7,11 +7,10 @@ import http from 'http' import logger from 'loglevel' import { CompleterResult, Interface, createInterface as Repl } from 'readline' import { Server, Socket } from 'socket.io' -import { Entity, Environment, Evaluation, Interpreter, Package, REPL, interprete, link, WRENatives as natives } from 'wollok-ts' +import { Entity, Environment, Evaluation, Interpreter, Package, REPL, interprete, link } from 'wollok-ts' import { logger as fileLogger } from '../logger' import { TimeMeasurer } from '../time-measurer' -import { ENTER, buildEnvironmentForProject, failureDescription, getDynamicDiagram, getFQN, handleError, publicPath, replIcon, sanitizeStackTrace, serverError, successDescription, validateEnvironment, valueDescription } from '../utils' - +import { ENTER, buildEnvironmentForProject, failureDescription, getDynamicDiagram, getFQN, handleError, publicPath, replIcon, sanitizeStackTrace, serverError, successDescription, validateEnvironment, valueDescription, Project } from '../utils' // TODO: // - autocomplete piola @@ -22,6 +21,7 @@ export type Options = { host: string, port: string, skipDiagram: boolean, + natives?: string } type DynamicDiagramClient = { @@ -108,6 +108,7 @@ export function interpreteLine(interpreter: Interpreter, line: string): string { export async function initializeInterpreter(autoImportPath: string | undefined, { project, skipValidations }: Options): Promise { let environment: Environment const timeMeasurer = new TimeMeasurer() + const proj = new Project(project) try { environment = await buildEnvironmentForProject(project) @@ -128,7 +129,7 @@ export async function initializeInterpreter(autoImportPath: string | undefined, const replPackage = new Package({ name: REPL }) environment = link([replPackage], environment) } - return new Interpreter(Evaluation.build(environment, natives)) + return new Interpreter(Evaluation.build(environment, await proj.readNatives())) } catch (error: any) { handleError(error) fileLogger.info({ message: `${replIcon} REPL execution - build failed for ${project}`, timeElapsed: timeMeasurer.elapsedTime(), ok: false, error: sanitizeStackTrace(error) }) diff --git a/src/commands/run.ts b/src/commands/run.ts index 677644c..fb0ea54 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -7,9 +7,9 @@ import logger from 'loglevel' import { join, relative } from 'path' import { Server, Socket } from 'socket.io' import { Asset, boardState, buildKeyPressEvent, queueEvent, SoundState, soundState, VisualState, visualState } from 'wollok-web-tools' -import { Environment, GAME_MODULE, interpret, Interpreter, Name, Package, RuntimeObject, WollokException, WRENatives as natives } from 'wollok-ts' +import { Environment, GAME_MODULE, interpret, Interpreter, Name, Natives, Package, RuntimeObject, WollokException } from 'wollok-ts' import { logger as fileLogger } from '../logger' -import { buildEnvironmentForProject, buildEnvironmentIcon, ENTER, failureDescription, folderIcon, gameIcon, getDynamicDiagram, handleError, isValidAsset, isValidImage, isValidSound, programIcon, publicPath, readPackageProperties, sanitizeStackTrace, serverError, successDescription, validateEnvironment, valueDescription } from '../utils' +import { buildEnvironmentForProject, buildEnvironmentIcon, ENTER, failureDescription, folderIcon, gameIcon, getDynamicDiagram, handleError, isValidAsset, isValidImage, isValidSound, programIcon, publicPath, readPackageProperties, sanitizeStackTrace, serverError, successDescription, validateEnvironment, valueDescription, Project } from '../utils' import { DummyProfiler, EventProfiler, TimeMeasurer } from './../time-measurer' const { time, timeEnd } = console @@ -34,6 +34,8 @@ type DynamicDiagramClient = { export default async function (programFQN: Name, options: Options): Promise { const { game, project, assets } = options const timeMeasurer = new TimeMeasurer() + const proj = new Project(project) + try { logger.info(`${game ? gameIcon : programIcon} Running program ${valueDescription(programFQN)} ${runner(game)} on ${valueDescription(project)}`) options.assets = game ? getAssetsFolder(options) : '' @@ -53,8 +55,8 @@ export default async function (programFQN: Name, options: Options): Promise(programFQN).parent as Package const dynamicDiagramClient = await initializeDynamicDiagram(programPackage, options, interpreter) const ioGame: Server | undefined = initializeGameClient(options) @@ -80,7 +82,7 @@ export default async function (programFQN: Name, options: Options): Promise { +export const getGameInterpreter = (environment: Environment, natives:Natives): Interpreter => { return interpret(environment, natives) } diff --git a/src/commands/test.ts b/src/commands/test.ts index 9032069..d4c52b9 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -1,8 +1,8 @@ import { bold, red } from 'chalk' import { time, timeEnd } from 'console' import logger from 'loglevel' -import { Entity, Environment, Node, Test, is, match, when, WRENatives as natives, interpret, Describe, count } from 'wollok-ts' -import { buildEnvironmentForProject, failureDescription, successDescription, valueDescription, validateEnvironment, handleError, ENTER, sanitizeStackTrace, buildEnvironmentIcon, testIcon, assertionError, warningDescription } from '../utils' +import { Entity, Environment, Node, Test, is, match, when, interpret, Describe, count } from 'wollok-ts' +import { buildEnvironmentForProject, failureDescription, successDescription, valueDescription, validateEnvironment, handleError, ENTER, sanitizeStackTrace, buildEnvironmentIcon, testIcon, assertionError, warningDescription, Project } from '../utils' import { logger as fileLogger } from '../logger' import { TimeMeasurer } from '../time-measurer' import { Package } from 'wollok-ts' @@ -14,7 +14,7 @@ export type Options = { describe: string | undefined, test: string | undefined, project: string - skipValidations: boolean + skipValidations: boolean, } class TestSearchMissError extends Error{} @@ -93,6 +93,7 @@ type TestExecutionError = { export default async function (filter: string | undefined, options: Options): Promise { try { validateParameters(filter, options) + const proj = new Project(options.project) const timeMeasurer = new TimeMeasurer() const { project, skipValidations } = options @@ -112,7 +113,7 @@ export default async function (filter: string | undefined, options: Options): Pr const debug = logger.getLevel() <= logger.levels.DEBUG if (debug) time('Run finished') - const interpreter = interpret(environment, natives) + const interpreter = interpret(environment, await proj.readNatives()) const testsFailed: TestExecutionError[] = [] let successes = 0 diff --git a/src/index.ts b/src/index.ts index b2b6878..4746b0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import repl from './commands/repl' import run from './commands/run' import test from './commands/test' import init from './commands/init' +import { addDependency, removeDependency, synchronizeDependencies } from './commands/dependencies' import logger from 'loglevel' import pkg from '../package.json' import { cyan } from 'chalk' @@ -54,7 +55,6 @@ updateNotifier().finally(() => { .option('-v, --verbose', 'print debugging information', false) .action(repl) - program.command('init') .description('Create a new Wollok project') .argument('[folder]', 'folder name, if not provided, the current folder will be used') @@ -64,8 +64,38 @@ updateNotifier().finally(() => { .option('-t, --noTest', 'avoids creating a test file', false) .option('-c, --noCI', 'avoids creating a file for CI', false) .option('-ng, --noGit', 'avoids initializing a git repository', false) + .option('-N, --natives [natives]', 'Folder name for native files and dependencies (defaults to root project).', undefined) .allowUnknownOption() .action(init) + const dependencyCommand = new Command('dependencies') + .description('Manage dependencies for a Wollok project') + + dependencyCommand + .command('add') + .description('Add a dependency to the project') + .argument('', 'Name of the package to add (e.g., lodash@latest)') + .option('-p, --project [path]', 'Path to project', process.cwd()) + .option('-v, --verbose', 'Print debugging information', false) + .allowUnknownOption() + .action(addDependency) + + dependencyCommand + .command('remove') + .description('Remove a dependency from the project') + .argument('', 'Name of the package to remove (e.g., lodash)') + .option('-p, --project [path]', 'Path to project', process.cwd()) + .option('-v, --verbose', 'Print debugging information', false) + .allowUnknownOption() + .action(removeDependency) + + dependencyCommand + .command('sync') + .description('Synchronize all dependencies') + .option('-p, --project [path]', 'Path to project', process.cwd()) + .option('-v, --verbose', 'Print debugging information', false) + .action(synchronizeDependencies) + + program.addCommand(dependencyCommand) program.parseAsync() }) \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index c3dea6d..ac0705d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,8 +5,17 @@ import globby from 'globby' import logger from 'loglevel' import path, { join } from 'path' import { getDataDiagram, VALID_IMAGE_EXTENSIONS, VALID_SOUND_EXTENSIONS } from 'wollok-web-tools' -import { buildEnvironment, Environment, getDynamicDiagramData, Interpreter, Package, Problem, validate, WOLLOK_EXTRA_STACK_TRACE_HEADER, WollokException } from 'wollok-ts' +import { buildEnvironment, Environment, getDynamicDiagramData, Interpreter, Natives, Package, Problem, validate, WOLLOK_EXTRA_STACK_TRACE_HEADER, WollokException, natives, List } from 'wollok-ts' import { ElementDefinition } from 'cytoscape' +import { register } from 'ts-node' + +register({ + transpileOnly: true, + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + }, +}) const { time, timeEnd } = console @@ -25,6 +34,46 @@ export const folderIcon = '🗂ī¸' // ══════════════════════════════════════════════════════════════════════════════════════════════════════════════════ // FILE / PATH HANDLING // ══════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +export class Project { + project!: string + properties: any = {} + + constructor(project: string) { + this.project = project + this.safeLoadJson() + } + + get sourceFolder() : string { + return this.project + } + + get packageJsonPath(): string { + return path.join(this.sourceFolder, 'package.json') + } + + private safeLoadJson() { + try { + const rawData = fs.readFileSync(this.packageJsonPath, 'utf-8') + this.properties = JSON.parse(rawData) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_error) { + // No package.json or it is invalid. This is not a real problem. + // Silence here or log? + } + } + + get nativesFolder(): string { + return join(this.sourceFolder, this.properties.natives || '') + } + + public async readNatives(): Promise{ + return readNatives(this.nativesFolder) + } + +} + export function relativeFilePath(project: string, filePath: string): string { return path.relative(project, filePath).split('.')[0] } @@ -87,6 +136,27 @@ export const handleError = (error: any): void => { logger.debug(failureDescription('ℹī¸ Stack trace:', error)) } +export async function readNatives(nativeFolder: string): Promise { + const paths = await globby('**/*.@(ts|cjs|js)', { cwd: nativeFolder }) + + const debug = logger.getLevel() <= logger.levels.DEBUG + + if (debug) time('Loading natives files') + + const nativesObjects: List = await Promise.all( + paths.map(async (filePath) => { + const fullPath = path.resolve(nativeFolder, filePath) + const importedModule = await import(fullPath) + const segments = filePath.replace(/\.(ts|js)$/, '').split(path.sep) + + return segments.reduceRight((acc, segment) => { return { [segment]: acc }}, importedModule.default || importedModule) + }) + ) + if (debug) timeEnd('Loading natives files') + + return natives(nativesObjects) +} + // ══════════════════════════════════════════════════════════════════════════════════════════════════════════════════ // PRINTING // ══════════════════════════════════════════════════════════════════════════════════════════════════════════════════ diff --git a/test/assertions.ts b/test/assertions.ts index 331d762..4957351 100644 --- a/test/assertions.ts +++ b/test/assertions.ts @@ -1,5 +1,5 @@ import { ElementDefinition } from 'cytoscape' -import { existsSync } from 'fs' +import { existsSync, readFileSync } from 'fs' type ElementDefinitionQuery = Partial @@ -7,7 +7,9 @@ declare global { export namespace Chai { interface Assertion { // TODO: split into the separate modules connect: (label: string, sourceLabel: string, targetLabel: string, width?: number, style?: string) => Assertion - pathExists(path: string): Assertion + pathExists: Assertion + jsonKeys(expectedKeys: string[]): Assertion; + jsonMatch(expected: Record): Assertion; } interface Include { @@ -19,17 +21,21 @@ declare global { export const pathAssertions: Chai.ChaiPlugin = (chai) => { const { Assertion } = chai - Assertion.addMethod('pathExists', function (path) { + Assertion.addProperty('pathExists', function () { + const path:string = this._obj const exists = existsSync(path) this.assert( exists, - 'expected path #{this} to exist', - 'expected path #{this} to not exist', - path + `expected this path to exist: '${path}'`, + `expected this path to not exist: '${path}'`, + true, + exists, + false ) }) } + export const diagramAssertions: Chai.ChaiPlugin = (chai) => { const { Assertion } = chai @@ -77,4 +83,73 @@ export const spyCalledWithSubstring = (spy: sinon.SinonStub, value: string, debu } } return false +} + +export const jsonAssertions: Chai.ChaiPlugin = (chai) => { + const { Assertion } = chai + + const getNestedValue = (obj: Record, path: string): any => + path.split('.').reduce((acc, key) => acc ? acc[key] : undefined, obj) + + const matchPartial = ( + expected: Record, + actual: Record, + prefix: string = '' + ): boolean => + Object.keys(expected).every((key) => { + const fullPath = prefix ? `${prefix}.${key}` : key + if (typeof expected[key] === 'object' && expected[key] !== null) { + if (typeof actual[key] !== 'object' || actual[key] === null) { + return false + } + return matchPartial(expected[key], actual[key], fullPath) + } + return actual[key] === expected[key] + }) + + const getJsonContent = (filePath: string): any => { + if (!existsSync(filePath)) { + throw new chai.AssertionError(`Expected file "${filePath}" to exist`) + } + + try { + return JSON.parse(readFileSync(filePath, 'utf8')) + } catch (error) { + throw new chai.AssertionError( + `Failed to parse JSON from file "${filePath}": ${String(error)}` + ) + } + } + + Assertion.addMethod('jsonKeys', function (expectedKeys: string[]) { + const filePath = this._obj as string + + const jsonContent = getJsonContent(filePath) + + expectedKeys.forEach((key) => + this.assert( + getNestedValue(jsonContent, key) !== undefined, + `Expected JSON to have key "${key} but"`, + `Expected JSON not to have key "${key}"`, + key, + jsonContent, + jsonContent + ) + ) + }) + + Assertion.addMethod('jsonMatch', function (expected: Record) { + const filePath = this._obj as string + + const jsonContent = getJsonContent(filePath) + + this.assert( + matchPartial(expected, jsonContent), + `Expected JSON to match: ${JSON.stringify(expected)}, but got: ${JSON.stringify(jsonContent)}`, + `Expected JSON not to match: ${JSON.stringify(expected)}`, + expected, + jsonContent, + true + ) + }) } \ No newline at end of file diff --git a/test/init.test.ts b/test/init.test.ts index a919d2d..cf49462 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -4,12 +4,13 @@ import { readFileSync, rmSync } from 'fs' import sinon from 'sinon' import init, { Options } from '../src/commands/init' import test from '../src/commands/test' -import { pathAssertions } from './assertions' +import { pathAssertions, jsonAssertions } from './assertions' chai.should() const expect = chai.expect use(pathAssertions) +use(jsonAssertions) const project = join('examples', 'init-examples', 'basic-example') const customFolderName = 'custom-folder' @@ -35,6 +36,7 @@ describe('testing init', () => { afterEach(() => { rmSync(project, { recursive: true, force: true }) rmSync(customFolderProject, { recursive: true, force: true }) + sinon.restore() }) @@ -47,7 +49,7 @@ describe('testing init', () => { expect(join(project, GITHUB_FOLDER, 'ci.yml')).to.pathExists expect(join(project, 'README.md')).to.pathExists expect(join(project, '.gitignore')).to.pathExists - expect(join(project, 'mainExample.wpgm')).to.pathExists + expect(join(project, 'mainExample.wpgm')).to.not.pathExists expect(join(project, '.git')).to.pathExists expect(join(project, '.git/HEAD')).to.pathExists expect(getResourceFolder()).to.be.undefined @@ -88,20 +90,21 @@ describe('testing init', () => { }) expect(join(project, 'pepita.wlk')).to.pathExists - expect(join(project, 'testPepita.wtest')).to.pathExists + expect(join(project, 'testPepita.wtest')).to.not.pathExists expect(join(project, 'package.json')).to.pathExists - expect(join(project, 'mainPepita.wpgm')).to.pathExists - expect(join(project, GITHUB_FOLDER, 'ci.yml')).to.pathExists + expect(join(project, 'mainPepita.wpgm')).to.not.pathExists + expect(join(project, GITHUB_FOLDER, 'ci.yml')).to.not.pathExists expect(join(project, '.gitignore')).to.pathExists expect(join(project, 'README.md')).to.pathExists }) it('should create files successfully with an argument for the folder name working in combination with project option', async () => { - init(customFolderName, baseOptions) + init(customFolderName, { ...baseOptions, name:'pepita' } ) + expect(join(customFolderProject, 'pepita.wlk')).to.pathExists expect(join(customFolderProject, 'testPepita.wtest')).to.pathExists - expect(join(customFolderProject, 'mainPepita.wpgm')).to.pathExists + expect(join(customFolderProject, 'mainPepita.wpgm')).to.not.pathExists expect(join(customFolderProject, 'package.json')).to.pathExists expect(join(customFolderProject, GITHUB_FOLDER, 'ci.yml')).to.pathExists expect(join(customFolderProject, 'README.md')).to.pathExists @@ -119,7 +122,7 @@ describe('testing init', () => { expect(join(project, GITHUB_FOLDER, 'ci.yml')).to.pathExists expect(join(project, 'README.md')).to.pathExists expect(join(project, '.gitignore')).to.pathExists - expect(join(project, 'mainExample.wpgm')).to.pathExists + expect(join(project, 'mainExample.wpgm')).to.not.pathExists expect(getResourceFolder()).to.be.undefined }) @@ -131,6 +134,29 @@ describe('testing init', () => { expect(processExitSpy.calledWith(1)).to.be.true }) + + it('should create a natives folder when it is required', () => { + init(undefined, { ...baseOptions, natives: 'myNatives' }) + + expect(join(project, 'myNatives')).to.pathExists + expect('package.json') + expect(join(project, 'package.json')).jsonMatch({ natives: 'myNatives' }) + + }) + + it('should create a natives nested folders when it is required', () => { + const nativesFolder =join('myNatives', 'myReallyNatives') + init(undefined, { ...baseOptions, natives: nativesFolder }) + expect(join(project, 'package.json')).jsonMatch({ natives: nativesFolder }) + + }) + + it('should not create a natives folders when it is not specified', () => { + init(undefined, baseOptions) + expect(join(project, 'package.json')).not.jsonKeys(['natives']) + }) + + }) const getResourceFolder = () => { diff --git a/test/repl.test.ts b/test/repl.test.ts index f6c3b66..8a33f69 100644 --- a/test/repl.test.ts +++ b/test/repl.test.ts @@ -258,6 +258,26 @@ describe('REPL', () => { }) + describe('User Natives', () => { + + const project = join('examples', 'user-natives' ) + const opt = { ...options, project: project } + + it('should execute user natives', async () => { + interpreter = await initializeInterpreter(join(project, 'rootFile.wlk'), opt) + + const result = interpreteLine(interpreter, 'myModel.nativeOne()') + result.should.be.equal(successDescription('1')) + }) + + it('should execute user natives in package', async () => { + interpreter = await initializeInterpreter(join(project, 'myPackage', 'myInnerFile.wlk'), opt) + + const result = interpreteLine(interpreter, 'packageModel.nativeTwo()') + result.should.be.equal(successDescription('2')) + }) + + }) }) diff --git a/test/run.test.ts b/test/run.test.ts index 7193cd3..732b1ae 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -84,7 +84,7 @@ describe('testing run', () => { } const environment = await buildEnvironmentForProgram(options) - const interpreter = getGameInterpreter(environment)! + const interpreter = getGameInterpreter(environment, await utils.readNatives(options.project))! const game = interpreter.object('wollok.game.game') interpreter.send('addVisual', game, interpreter.object('mainGame.elementoVisual')) diff --git a/test/test.test.ts b/test/test.test.ts index 65caf89..6a6fe7c 100644 --- a/test/test.test.ts +++ b/test/test.test.ts @@ -141,7 +141,6 @@ describe('Test', () => { expect(tests[1].name).to.equal('"another test"') expect(tests[2].name).to.equal('"another test with longer name"') }) - it('should filter by file & describe & test using file & describe & test option', () => { const tests = getTarget(environment, undefined, { ...emptyOptions, @@ -332,7 +331,6 @@ describe('Test', () => { ...emptyOptions, file: 'test-one.wtest', }) - expect(processExitSpy.callCount).to.equal(0) expect(spyCalledWithSubstring(loggerInfoSpy, 'Running 3 tests')).to.be.true expect(spyCalledWithSubstring(loggerInfoSpy, '3 passed')).to.be.true @@ -347,7 +345,6 @@ describe('Test', () => { ...emptyOptions, file: 'non-existing-file.wtest', }) - expect(processExitSpy.callCount).to.equal(0) expect(spyCalledWithSubstring(loggerInfoSpy, 'Running 0 tests')).to.be.true expect(spyCalledWithSubstring(loggerErrorSpy, 'File \'non-existing-file.wtest\' not found')).to.be.true diff --git a/test/userNatives.test.ts b/test/userNatives.test.ts new file mode 100644 index 0000000..7527d63 --- /dev/null +++ b/test/userNatives.test.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai' +import logger from 'loglevel' +import { join } from 'path' +import sinon from 'sinon' +import test from '../src/commands/test' +import { logger as fileLogger } from '../src/logger' +import { spyCalledWithSubstring } from './assertions' + +describe('UserNatives', () => { + let fileLoggerInfoSpy: sinon.SinonStub + let loggerInfoSpy: sinon.SinonStub + let processExitSpy: sinon.SinonStub + + + const options = { + project: join('examples', 'user-natives'), + skipValidations: false, + file: undefined, + describe: undefined, + test: undefined, + } + + + beforeEach(() => { + loggerInfoSpy = sinon.stub(logger, 'info') + fileLoggerInfoSpy = sinon.stub(fileLogger, 'info') + processExitSpy = sinon.stub(process, 'exit') + }) + + afterEach(() => { + sinon.restore() + }) + + it('passes all the tests successfully and exits normally', async () => { + await test(undefined, options ) + + expect(processExitSpy.callCount).to.equal(0) + expect(spyCalledWithSubstring(loggerInfoSpy, 'Running 1 tests')).to.be.true + expect(spyCalledWithSubstring(loggerInfoSpy, '1 passed')).to.be.true + expect(spyCalledWithSubstring(loggerInfoSpy, '0 failed')).to.be.false + expect(spyCalledWithSubstring(loggerInfoSpy, '0 errored')).to.be.false + expect(fileLoggerInfoSpy.calledOnce).to.be.true + expect(fileLoggerInfoSpy.firstCall.firstArg.result).to.deep.equal({ + ok: 1, + failed: 0, + errored: 0, + }) + }) +}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index fa748bb..8376fc8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,6 @@ "esModuleInterop": true, "experimentalDecorators": true }, - "include": ["test/**/*.ts", "src/**/*.ts"], + "include": ["test/**/*.ts", "src/**/*.ts", "examples/**/*.ts"], "exclude": ["**/*.js", "node_modules"] }