Compare commits

..

1 Commits

Author SHA1 Message Date
undergroundwires
0c8412c467 Improve disabling of Windows services
- Unify way of disabling Windows services using templating.
- Remove false-positive error messages.
- Use smarter logic to start/stop service.

Logic improvements include:
  - Check if service is running before stopping/starting the service.
  - Do not start the service it's not an Automatic service.
  - Do not write stderr if service cannot be stopped/started as it's not
    not the main goal of the function.
  - Check whether service is already disabled.

Add more documentation about the disabled Windows service.

Script: Disable diagnostics telemetry
 - Add missing revert script
 - Add more granular control for each service.
2021-11-21 20:58:44 +01:00
366 changed files with 31755 additions and 37563 deletions

View File

@@ -1,7 +0,0 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100

View File

@@ -1,115 +0,0 @@
const { rules: baseStyleRules } = require('eslint-config-airbnb-base/rules/style');
module.exports = {
root: true,
env: {
node: true,
},
extends: [
// Vue specific rules, eslint-plugin-vue
// Added by Vue CLI
'plugin:vue/essential',
// Extends eslint-config-airbnb
// Added by Vue CLI
// Here until https://github.com/vuejs/eslint-config-airbnb/issues/23 is done
'@vue/airbnb',
// Extends @typescript-eslint/recommended
// Uses the recommended rules from the @typescript-eslint/eslint-plugin
// Added by Vue CLI
'@vue/typescript/recommended',
],
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
...getOwnRules(),
...getTurnedOffBrokenRules(),
...getOpinionatedRuleOverrides(),
...getTodoRules(),
},
overrides: [
{
files: [
'**/__tests__/*.{j,t}s?(x)',
'**/tests/unit/**/*.spec.{j,t}s?(x)',
],
env: {
mocha: true,
},
},
{
files: ['**/tests/**/*.{j,t}s?(x)'],
rules: {
'no-console': 'off',
},
},
],
};
function getOwnRules() {
return {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'linebreak-style': ['error', 'unix'], // This is also enforced in .editorconfig and .gitattributes files
'import/order': [ // Enforce strict import order taking account into aliases
'error',
{
groups: [ // Enforce more strict order than AirBnb
'builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
pathGroups: [ // Fix manually configured paths being incorrectly grouped as "external"
'@/**', // @/..
'@tests/**', // @tests/.. (not matching anything after @** because there can be third parties as well)
'js-yaml-loader!@/**', // E.g. js-yaml-loader!@/..
].map((pattern) => ({ pattern, group: 'internal' })),
},
],
};
}
function getTodoRules() { // Should be worked on separate future commits
return {
'import/no-extraneous-dependencies': 'off',
// Requires webpack configuration change with import '..yaml' files.
'import/no-webpack-loader-syntax': 'off',
'import/extensions': 'off',
'import/no-unresolved': 'off',
// Accessibility improvements:
'vuejs-accessibility/form-control-has-label': 'off',
'vuejs-accessibility/click-events-have-key-events': 'off',
'vuejs-accessibility/anchor-has-content': 'off',
'vuejs-accessibility/accessible-emoji': 'off',
};
}
function getTurnedOffBrokenRules() {
return {
// Broken in TypeScript
'no-useless-constructor': 'off', // Cannot interpret TypeScript constructors
'no-shadow': 'off', // Fails with TypeScript enums
};
}
function getOpinionatedRuleOverrides() {
return {
// https://erkinekici.com/articles/linting-trap#no-use-before-define
'no-use-before-define': 'off',
// https://erkinekici.com/articles/linting-trap#arrow-body-style
'arrow-body-style': 'off',
// https://erkinekici.com/articles/linting-trap#no-plusplus
'no-plusplus': 'off',
// https://erkinekici.com/articles/linting-trap#no-param-reassign
'no-param-reassign': 'off',
// https://erkinekici.com/articles/linting-trap#class-methods-use-this
'class-methods-use-this': 'off',
// https://erkinekici.com/articles/linting-trap#importprefer-default-export
'import/prefer-default-export': 'off',
// https://erkinekici.com/articles/linting-trap#disallowing-for-of
// Original: https://github.com/airbnb/javascript/blob/d8cb404da74c302506f91e5928f30cc75109e74d/packages/eslint-config-airbnb-base/rules/style.js#L333-L351
'no-restricted-syntax': [
baseStyleRules['no-restricted-syntax'][0],
...baseStyleRules['no-restricted-syntax'].slice(1).filter((rule) => rule.selector !== 'ForOfStatement'),
],
};
}

6
.gitattributes vendored
View File

@@ -1,6 +0,0 @@
# Prevent Git from auto-converting to CRLF on Windows, and convert to LF on checkin.
# * : All files
# text=auto : If Git decides content it text, it converts CRLF to LF on checkin.
# eol=lf : forces Git to normalize line endings to LF on checkin and prevents conversion
# to CRLF when the file is checked out.
* text=auto eol=lf

View File

@@ -1,4 +1,4 @@
name: release-git name: Bump & release
on: on:
push: # Ensure a new release is created for each new tag push: # Ensure a new release is created for each new tag

View File

@@ -1,59 +0,0 @@
name: build-checks
on:
push:
pull_request:
jobs:
build-web:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
mode: [ development, test, production ]
fail-fast: false # Allows to see results from other combinations
runs-on: ${{ matrix.os }}-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: actions/setup-node@v1
with:
node-version: 15.x
-
name: Install dependencies
run: npm ci
-
name: Build
run: npm run build -- --mode ${{ matrix.mode }}
# A new job is used due to environments/modes different from Vue CLI, https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1626
build-desktop:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
mode: [ development, production ] # "test" is not supported https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1627
fail-fast: false # Allows to see results from other combinations
runs-on: ${{ matrix.os }}-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: actions/setup-node@v1
with:
node-version: 15.x
-
name: Install dependencies
run: npm ci
-
name: Install cross-env
# Used to set NODE_ENV due to https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1626
run: npm install --global cross-env
-
name: Build
run: |-
cross-env-shell NODE_ENV=${{ matrix.mode }}
npm run electron:build -- --publish never --mode ${{ matrix.mode }}

View File

@@ -1,4 +1,4 @@
name: release-desktop name: Deploy desktop
on: on:
release: release:

View File

@@ -1,4 +1,4 @@
name: release-site name: Deploy site
on: on:
release: release:

View File

@@ -1,4 +1,4 @@
name: quality-checks name: Quality checks
on: [ push, pull_request ] on: [ push, pull_request ]
@@ -8,13 +8,12 @@ jobs:
strategy: strategy:
matrix: matrix:
lint-command: lint-command:
- npm run lint:eslint - npm run lint:vue
- npm run lint:yaml - npm run lint:yaml
- npm run lint:md - npm run lint:md
- npm run lint:md:relative-urls - npm run lint:md:relative-urls
- npm run lint:md:consistency - npm run lint:md:consistency
os: [ macos, ubuntu, windows ] fail-fast: false # So it continues with other commands if one fails
fail-fast: false # Still interested to see results from other combinations
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2

View File

@@ -1,4 +1,4 @@
name: security-checks name: Security checks
on: on:
push: push:

View File

@@ -1,9 +1,9 @@
name: integration-tests name: Test
on: on:
push: push:
pull_request: pull_request:
schedule: # To get notified about problems from third party dependencies schedule: # for integration tests
- cron: '0 0 * * 0' # at 00:00 on every Sunday - cron: '0 0 * * 0' # at 00:00 on every Sunday
jobs: jobs:
@@ -25,6 +25,9 @@ jobs:
- -
name: Install dependencies name: Install dependencies
run: npm ci run: npm ci
-
name: Run unit tests
run: npm run test:unit
- -
name: Run integration tests name: Run integration tests
run: npm run test:integration run: npm run test:integration

View File

@@ -1,28 +0,0 @@
name: e2e-tests
on:
push:
pull_request:
jobs:
run-tests:
strategy:
matrix:
os: [macos, ubuntu, windows]
fail-fast: false # So it still runs on other OSes if one of them fails
runs-on: ${{ matrix.os }}-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: actions/setup-node@v1
with:
node-version: 15.x
-
name: Install dependencies
run: npm ci
-
name: Run e2e tests
run: npm run test:e2e -- --headless

View File

@@ -1,28 +0,0 @@
name: unit-tests
on:
push:
pull_request:
jobs:
run-tests:
strategy:
matrix:
os: [macos, ubuntu, windows]
fail-fast: false # So it still runs on other OSes if one of them fails
runs-on: ${{ matrix.os }}-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: actions/setup-node@v1
with:
node-version: 15.x
-
name: Install dependencies
run: npm ci
-
name: Run unit tests
run: npm run test:unit

3
.gitignore vendored
View File

@@ -4,6 +4,3 @@ dist/
.vscode .vscode
#Electron-builder output #Electron-builder output
/dist_electron /dist_electron
# Cypress
/tests/e2e/screenshots
/tests/e2e/videos

View File

@@ -1,20 +1,5 @@
# Changelog # Changelog
## 0.11.2 (2021-12-03)
* Fix Windows TrustedInstaller session errors | [20a0071](https://github.com/undergroundwires/privacy.sexy/commit/20a0071c0d3d769a8f31218abdbfc4cafa25c6ff)
* Improve tests for `UserSelection` | [2f90cac](https://github.com/undergroundwires/privacy.sexy/commit/2f90cac52ab9e57615aeec41f9daa842bce770a5)
* Fix disabling/enabling Defender on Windows #104 | [2e08293](https://github.com/undergroundwires/privacy.sexy/commit/2e082932c952b0849ab2b8709ff0c75293b88e95)
* Refactor Saas naming, structure and modules | [bf83c58](https://github.com/undergroundwires/privacy.sexy/commit/bf83c58982ffa178facc6d35e50c7f1eac7ff236)
* Fix Defender features errors in Windows #104 | [d7761ab](https://github.com/undergroundwires/privacy.sexy/commit/d7761ab30e7f1e10a2919c196804d67511d6163a)
* Fix unintendedly inlined Windows scripts | [f2d9881](https://github.com/undergroundwires/privacy.sexy/commit/f2d988138257ff184884e4adc83c39e3bc247e9b)
* Fix Defender error due to non-english Windows #104 | [7c02ffb](https://github.com/undergroundwires/privacy.sexy/commit/7c02ffb6c95382b94f0b05e6f259cc418ec91c93)
* Improve and unify disabling of Windows services | [70cdf38](https://github.com/undergroundwires/privacy.sexy/commit/70cdf3865a0de3214fc9e26fbdada4b0cb413c46)
* Improve Windows defender docs and errors #104 | [d2518b1](https://github.com/undergroundwires/privacy.sexy/commit/d2518b11a7774ec58b9b46a691e2f013855bf0f9)
* Unrecommend and complete Windows Push Notif. #101 | [c65209e](https://github.com/undergroundwires/privacy.sexy/commit/c65209e6a99230f15ace8955e8d5a6f3333d146b)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.11.1...0.11.2)
## 0.11.1 (2021-11-04) ## 0.11.1 (2021-11-04)
* Update dependencies | [64631a4](https://github.com/undergroundwires/privacy.sexy/commit/64631a4552fad7f7b06286aba8d3ca2d731f9342) * Update dependencies | [64631a4](https://github.com/undergroundwires/privacy.sexy/commit/64631a4552fad7f7b06286aba8d3ca2d731f9342)

111
README.md
View File

@@ -2,104 +2,21 @@
> Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆 > Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆
<!-- markdownlint-disable MD033 --> [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](./CONTRIBUTING.md)
<p align="center"> [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript)
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md"> [![Maintainability](https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability)](https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability)
<img [![Tests status](https://github.com/undergroundwires/privacy.sexy/workflows/Test/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions)
alt="contributions are welcome" [![Quality checks status](https://github.com/undergroundwires/privacy.sexy/workflows/Quality%20checks/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions)
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat" [![Security checks status](https://github.com/undergroundwires/privacy.sexy/workflows/Security%20checks/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions)
/> [![Bump & release status](https://github.com/undergroundwires/privacy.sexy/workflows/Bump%20&%20release/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions)
</a> [![Deploy status](https://github.com/undergroundwires/privacy.sexy/workflows/Build%20&%20deploy/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions)
<!-- Code quality --> [![Auto-versioned by bump-everywhere](https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true)](https://github.com/undergroundwires/bump-everywhere)
<br />
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript">
<img
alt="Language grade: JavaScript/TypeScript"
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
/>
</a>
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability">
<img
alt="Maintainability"
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
/>
</a>
<!-- Tests -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml">
<img
alt="Unit tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml">
<img
alt="Integration tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml">
<img
alt="E2E tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
/>
</a>
<!-- Checks -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml">
<img
alt="Quality checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml">
<img
alt="Security checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml">
<img
alt="Build checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
/>
</a>
<!-- Release -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml">
<img
alt="Git release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml">
<img
alt="Site release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml">
<img
alt="Desktop application release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
/>
</a>
<!-- Others -->
<br />
<a href="https://github.com/undergroundwires/bump-everywhere">
<img
alt="Auto-versioned by bump-everywhere"
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"
/>
</a>
</p>
<!-- markdownlint-restore -->
## Get started ## Get started
- Online version at [https://privacy.sexy](https://privacy.sexy) - Online version at [https://privacy.sexy](https://privacy.sexy)
- 💡 No need to run any compiled software on your computer. - 💡 No need to run any compiled software on your computer.
- Alternatively download offline version for [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.11.2/privacy.sexy-Setup-0.11.2.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.11.2/privacy.sexy-0.11.2.dmg) or [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.11.2/privacy.sexy-0.11.2.AppImage). - Alternatively download offline version for [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.11.1/privacy.sexy-Setup-0.11.1.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.11.1/privacy.sexy-0.11.1.dmg) or [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.11.1/privacy.sexy-0.11.1.AppImage).
- 💡 Single click to execute your script. - 💡 Single click to execute your script.
- ❗ Come back regularly to apply latest version for stronger privacy and security. - ❗ Come back regularly to apply latest version for stronger privacy and security.
@@ -132,9 +49,6 @@
- Testing - Testing
- Run unit tests: `npm run test:unit` - Run unit tests: `npm run test:unit`
- Run integration tests: `npm run test:integration` - Run integration tests: `npm run test:integration`
- Run e2e (end-to-end) tests
- Interactive mode with GUI: `npm run test:e2e`
- Headless mode without GUI: `npm run test:e2e -- --headless`
- Lint: `npm run lint` - Lint: `npm run lint`
- **Desktop app** - **Desktop app**
- Development: `npm run electron:serve` - Development: `npm run electron:serve`
@@ -143,8 +57,8 @@
- Development: `npm run serve` to compile & hot-reload for development. - Development: `npm run serve` to compile & hot-reload for development.
- Production: `npm run build` to prepare files for distribution. - Production: `npm run build` to prepare files for distribution.
- Or run using Docker: - Or run using Docker:
1. Build: `docker build -t undergroundwires/privacy.sexy:0.11.2 .` 1. Build: `docker build -t undergroundwires/privacy.sexy:0.11.1 .`
2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy-0.11.2 undergroundwires/privacy.sexy:0.11.2` 2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy-0.11.1 undergroundwires/privacy.sexy:0.11.1`
## Architecture overview ## Architecture overview
@@ -170,6 +84,5 @@
- CI/CD is fully automated for this repo using different GIT events & GitHub actions. - CI/CD is fully automated for this repo using different GIT events & GitHub actions.
- Versioning, tagging, creation of `CHANGELOG.md` and releasing is automated using [bump-everywhere](https://github.com/undergroundwires/bump-everywhere) action - Versioning, tagging, creation of `CHANGELOG.md` and releasing is automated using [bump-everywhere](https://github.com/undergroundwires/bump-everywhere) action
- Everything that's merged in the master goes directly to production. - Everything that's merged in the master goes directly to production.
- 📖 Read more on [CI/CD pipelines](./docs/ci-cd.md)
[![CI/CD to AWS with GitHub Actions](img/architecture/gitops.png)](.github/workflows/) [![CI/CD to AWS with GitHub Actions](img/architecture/gitops.png)](.github/workflows/)

View File

@@ -1,5 +1,5 @@
module.exports = { module.exports = {
presets: [ presets: [
'@vue/cli-plugin-babel/preset', '@vue/cli-plugin-babel/preset'
], ]
}; }

View File

@@ -1,3 +0,0 @@
{
"pluginsFile": "tests/e2e/plugins/index.js"
}

View File

@@ -1,19 +0,0 @@
# Pipelines
Pipelines are found under [`.github/workflows`](./../.github/workflows).
## Pipeline types
They are categorized based on their type:
- `tests`: Different types of tests to verify functionality.
- `checks`: Other controls such as vulnerability scans or styling checks.
- `release`: Pipelines used for release of deployment such as building and testing.
## Naming conventions
Pipeline files are named using: **`<type>.<name>.yaml`**.
**`type`**: Sub-folders do not work for GitHub workflows so that's why `<type>.` prefix is used. See also [pipeline types](#pipeline-types).
**`name`**: Pipeline themselves are named using kebab case. It allows for easier URL references for their status badges. E.g. file name `tests.unit.yaml`, pipeline name: `name: unit-tests`

View File

@@ -3,59 +3,41 @@
- There are two different types of tests executed: - There are two different types of tests executed:
1. [Unit tests](#unit-tests) 1. [Unit tests](#unit-tests)
2. [Integration tests](#integration-tests) 2. [Integration tests](#integration-tests)
3. [End-to-end (E2E) tests](#e2e-tests)
- All tests
- Uses [Mocha](https://mochajs.org/) and [Chai](https://www.chaijs.com/).
- Are written in files that includes `.spec` extension.
- 💡 You can use path/module alias `@/tests` in import statements. - 💡 You can use path/module alias `@/tests` in import statements.
## Unit tests ## Unit tests
- Tests each component in isolation. - Tests each component in isolation
- Defined in [`./tests/unit`](./../tests/unit). - Defined in [`./tests/unit`](./../tests/unit)
- They follow same folder structure as [`./src`](./../src). - They follow same folder structure as [`./src`](./../src)
### Naming ### Naming
- Each test suite first describe the system under test. - Each test suite first describe the system under test
- E.g. tests for class `Application` is categorized under `Application`. - E.g. tests for class `Application` is categorized under `Application`
- Tests for specific methods are categorized under method name (if applicable). - Tests for specific methods are categorized under method name (if applicable)
- E.g. test for `run()` is categorized under `run`. - E.g. test for `run()` is categorized under `run`
### Act, arrange, assert ### Act, arrange, assert
- Tests use act, arrange and assert (AAA) pattern when applicable. - Tests use act, arrange and assert (AAA) pattern when applicable
- **Arrange** - **Arrange**
- Should set up the test case. - Should set up the test case
- Starts with comment line `// arrange`. - Starts with comment line `// arrange`
- **Act** - **Act**
- Should cover the main thing to be tested. - Should cover the main thing to be tested
- Starts with comment line `// act`. - Starts with comment line `// act`
- **Assert** - **Assert**
- Should elicit some sort of response. - Should elicit some sort of response
- Starts with comment line `// assert`. - Starts with comment line `// assert`
### Stubs ### Stubs
- Stubs are defined in [`./tests/stubs`](./../tests/unit/stubs). - Stubs are defined in [`./tests/stubs`](./../tests/unit/stubs)
- They implement dummy behavior to be functional. - They implement dummy behavior to be functional
## Integration tests ## Integration tests
- Tests functionality of a component in combination with others (not isolated). - Tests functionality of a component in combination with others (not isolated)
- Ensure dependencies to third parties work as expected. - Ensure dependencies to third parties work as expected
- Defined in [`./tests/integration`](./../tests/integration). - Defined in [`./tests/integration`](./../tests/integration)
## E2E tests
- Test the functionality and performance of a running application.
- E2E tests are configured by vue plugin [`e2e-cypress`](https://github.com/vuejs/vue-cli/tree/dev/packages/@vue/cli-plugin-e2e-cypress#readme) for Vue CLI.
- Names and folders are structured logically based on tests.
- The structure is following:
- [`cypress.json`](./../cypress.json): Cypress configuration file.
- [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder.
- [`/specs/`](./../tests/e2e/specs/): Test files, test are named with `.spec.js` extension.
- [`/plugins/index.js`](./../tests/e2e/plugins/index.js): Plugin file executed before project is loaded.
- [`/support/index.js`](./../tests/e2e/support/index.js): Support file, runs before every single spec file.
- *(Ignored)* `/videos`: Asset folder for videos taken during tests.
- *(Ignored)* `/screenshots`: Asset folder for Screenshots taken during tests.

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 579 KiB

36883
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.11.2", "version": "0.11.1",
"private": true, "private": true,
"description": "Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆", "description": "Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆",
"author": "undergroundwires", "author": "undergroundwires",
@@ -8,18 +8,17 @@
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit", "test:unit": "vue-cli-service test:unit",
"test:e2e": "vue-cli-service test:e2e", "test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\"",
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml", "lint": "npm run lint:vue && npm run lint:yaml && npm run lint:md && npm run lint:md:relative-urls && npm run lint:md:consistency",
"electron:build": "vue-cli-service electron:build", "electron:build": "vue-cli-service electron:build",
"electron:serve": "vue-cli-service electron:serve", "electron:serve": "vue-cli-service electron:serve",
"lint:md": "markdownlint **/*.md --ignore node_modules", "lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent", "lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links", "lint:md:relative-urls": "remark . --frail --use remark-validate-links",
"lint:eslint": "vue-cli-service lint --no-fix --mode production", "lint:vue": "vue-cli-service lint --no-fix",
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml", "lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps", "postuninstall": "electron-builder install-app-deps"
"test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\""
}, },
"main": "background.js", "main": "background.js",
"dependencies": { "dependencies": {
@@ -48,27 +47,16 @@
"@types/chai": "^4.2.22", "@types/chai": "^4.2.22",
"@types/file-saver": "^2.0.3", "@types/file-saver": "^2.0.3",
"@types/mocha": "^9.0.0", "@types/mocha": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^5.4.0", "@vue/cli-plugin-babel": "^4.5.14",
"@typescript-eslint/parser": "^5.4.0", "@vue/cli-plugin-typescript": "^4.5.14",
"@vue/cli-plugin-babel": "~5.0.0-rc.1", "@vue/cli-plugin-unit-mocha": "^4.5.14",
"@vue/cli-plugin-e2e-cypress": "~5.0.0-rc.1", "@vue/cli-service": "^4.5.14",
"@vue/cli-plugin-eslint": "~5.0.0-rc.1",
"@vue/cli-plugin-typescript": "~5.0.0-rc.1",
"@vue/cli-plugin-unit-mocha": "~5.0.0-rc.1",
"@vue/cli-service": "~5.0.0-rc.1",
"@vue/eslint-config-airbnb": "^6.0.0",
"@vue/eslint-config-typescript": "^9.1.0",
"@vue/test-utils": "1.2.2", "@vue/test-utils": "1.2.2",
"chai": "^4.3.4", "chai": "^4.3.4",
"cypress": "^8.3.0",
"electron": "^15.3.0", "electron": "^15.3.0",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-log": "^4.4.1", "electron-log": "^4.4.1",
"electron-updater": "^4.3.9", "electron-updater": "^4.3.9",
"eslint": "^7.32.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-vue": "^8.0.3",
"eslint-plugin-vuejs-accessibility": "^1.1.0",
"js-yaml-loader": "^1.2.2", "js-yaml-loader": "^1.2.2",
"markdownlint-cli": "^0.29.0", "markdownlint-cli": "^0.29.0",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
@@ -78,16 +66,12 @@
"remark-validate-links": "^11.0.1", "remark-validate-links": "^11.0.1",
"sass": "^1.43.3", "sass": "^1.43.3",
"sass-loader": "10.2.0", "sass-loader": "10.2.0",
"ts-loader": "9.0.1",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"typescript": "^4.4.4", "typescript": "^4.4.4",
"vue-cli-plugin-electron-builder": "^2.1.1", "vue-cli-plugin-electron-builder": "^2.1.1",
"vue-template-compiler": "^2.6.14", "vue-template-compiler": "^2.6.14",
"yaml-lint": "^1.2.4" "yaml-lint": "^1.2.4"
}, },
"//devDependencies": {
"ts-loader": "Here as workaround for vue-cli-plugin-electron-builder using older webpack 4"
},
"homepage": "https://privacy.sexy", "homepage": "https://privacy.sexy",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -1,5 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
autoprefixer: {}, autoprefixer: {}
}, }
}; }

View File

@@ -7,18 +7,15 @@ export type ApplicationGetter = () => IApplication;
const ApplicationGetter: ApplicationGetter = parseApplication; const ApplicationGetter: ApplicationGetter = parseApplication;
export class ApplicationFactory implements IApplicationFactory { export class ApplicationFactory implements IApplicationFactory {
public static readonly Current: IApplicationFactory = new ApplicationFactory(ApplicationGetter); public static readonly Current: IApplicationFactory = new ApplicationFactory(ApplicationGetter);
private readonly getter: AsyncLazy<IApplication>;
private readonly getter: AsyncLazy<IApplication>; protected constructor(costlyGetter: ApplicationGetter) {
if (!costlyGetter) {
protected constructor(costlyGetter: ApplicationGetter) { throw new Error('undefined getter');
if (!costlyGetter) { }
throw new Error('undefined getter'); this.getter = new AsyncLazy<IApplication>(() => Promise.resolve(costlyGetter()));
}
public getApp(): Promise<IApplication> {
return this.getter.getValue();
} }
this.getter = new AsyncLazy<IApplication>(() => Promise.resolve(costlyGetter()));
}
public getApp(): Promise<IApplication> {
return this.getter.getValue();
}
} }

View File

@@ -1,21 +1,21 @@
// Compares to Array<T> objects for equality, ignoring order // Compares to Array<T> objects for equality, ignoring order
export function scrambledEqual<T>(array1: readonly T[], array2: readonly T[]) { export function scrambledEqual<T>(array1: readonly T[], array2: readonly T[]) {
if (!array1) { throw new Error('undefined first array'); } if (!array1) { throw new Error('undefined first array'); }
if (!array2) { throw new Error('undefined second array'); } if (!array2) { throw new Error('undefined second array'); }
const sortedArray1 = sort(array1); const sortedArray1 = sort(array1);
const sortedArray2 = sort(array2); const sortedArray2 = sort(array2);
return sequenceEqual(sortedArray1, sortedArray2); return sequenceEqual(sortedArray1, sortedArray2);
function sort(array: readonly T[]) { function sort(array: readonly T[]) {
return array.slice().sort(); return array.slice().sort();
} }
} }
// Compares to Array<T> objects for equality in same order // Compares to Array<T> objects for equality in same order
export function sequenceEqual<T>(array1: readonly T[], array2: readonly T[]) { export function sequenceEqual<T>(array1: readonly T[], array2: readonly T[]) {
if (!array1) { throw new Error('undefined first array'); } if (!array1) { throw new Error('undefined first array'); }
if (!array2) { throw new Error('undefined second array'); } if (!array2) { throw new Error('undefined second array'); }
if (array1.length !== array2.length) { if (array1.length !== array2.length) {
return false; return false;
} }
return array1.every((val, index) => val === array2[index]); return array1.every((val, index) => val === array2[index]);
} }

View File

@@ -1,63 +1,54 @@
// Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611 // Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611
export type EnumType = number | string; export type EnumType = number | string;
export type EnumVariable<T extends EnumType, TEnumValue extends EnumType> export type EnumVariable<T extends EnumType, TEnumValue extends EnumType> = { [key in T]: TEnumValue };
= { [key in T]: TEnumValue };
export interface IEnumParser<TEnum> { export interface IEnumParser<TEnum> {
parseEnum(value: string, propertyName: string): TEnum; parseEnum(value: string, propertyName: string): TEnum;
} }
export function createEnumParser<T extends EnumType, TEnumValue extends EnumType>( export function createEnumParser<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>): IEnumParser<TEnumValue> {
): IEnumParser<TEnumValue> { return {
return { parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable),
parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable), };
};
} }
function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>( function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
value: string, value: string,
enumName: string, enumName: string,
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>): TEnumValue {
): TEnumValue { if (!value) {
if (!value) { throw new Error(`undefined ${enumName}`);
throw new Error(`undefined ${enumName}`); }
} if (typeof value !== 'string') {
if (typeof value !== 'string') { throw new Error(`unexpected type of ${enumName}: "${typeof value}"`);
throw new Error(`unexpected type of ${enumName}: "${typeof value}"`); }
} const casedValue = getEnumNames(enumVariable)
const casedValue = getEnumNames(enumVariable) .find((enumValue) => enumValue.toLowerCase() === value.toLowerCase());
.find((enumValue) => enumValue.toLowerCase() === value.toLowerCase()); if (!casedValue) {
if (!casedValue) { throw new Error(`unknown ${enumName}: "${value}"`);
throw new Error(`unknown ${enumName}: "${value}"`); }
} return enumVariable[casedValue as keyof typeof enumVariable];
return enumVariable[casedValue as keyof typeof enumVariable];
} }
export function getEnumNames export function getEnumNames<T extends EnumType, TEnumValue extends EnumType>(
<T extends EnumType, TEnumValue extends EnumType>( enumVariable: EnumVariable<T, TEnumValue>): string[] {
enumVariable: EnumVariable<T, TEnumValue>, return Object
): string[] { .values(enumVariable)
return Object .filter((enumMember) => typeof enumMember === 'string') as string[];
.values(enumVariable)
.filter((enumMember) => typeof enumMember === 'string') as string[];
} }
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>( export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>): TEnumValue[] {
): TEnumValue[] { return getEnumNames(enumVariable)
return getEnumNames(enumVariable) .map((level) => enumVariable[level]) as TEnumValue[];
.map((level) => enumVariable[level]) as TEnumValue[];
} }
export function assertInRange<T extends EnumType, TEnumValue extends EnumType>( export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(
value: TEnumValue, value: TEnumValue,
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>) {
) { if (value === undefined) {
if (value === undefined) { throw new Error('undefined enum value');
throw new Error('undefined enum value'); }
} if (!(value in enumVariable)) {
if (!(value in enumVariable)) { throw new RangeError(`enum value "${value}" is out of range`);
throw new RangeError(`enum value "${value}" is out of range`); }
}
} }

View File

@@ -1,5 +1,5 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
export interface IScriptingLanguageFactory<T> { export interface IScriptingLanguageFactory<T> {
create(language: ScriptingLanguage): T; create(language: ScriptingLanguage): T;
} }

View File

@@ -1,30 +1,31 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { assertInRange } from '@/application/Common/Enum';
import { IScriptingLanguageFactory } from './IScriptingLanguageFactory'; import { IScriptingLanguageFactory } from './IScriptingLanguageFactory';
import { assertInRange } from '@/application/Common/Enum';
type Getter<T> = () => T; type Getter<T> = () => T;
export abstract class ScriptingLanguageFactory<T> implements IScriptingLanguageFactory<T> { export abstract class ScriptingLanguageFactory<T> implements IScriptingLanguageFactory<T> {
private readonly getters = new Map<ScriptingLanguage, Getter<T>>(); private readonly getters = new Map<ScriptingLanguage, Getter<T>>();
public create(language: ScriptingLanguage): T { public create(language: ScriptingLanguage): T {
assertInRange(language, ScriptingLanguage); assertInRange(language, ScriptingLanguage);
if (!this.getters.has(language)) { if (!this.getters.has(language)) {
throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`); throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`);
}
const getter = this.getters.get(language);
const instance = getter();
return instance;
} }
const getter = this.getters.get(language);
const instance = getter();
return instance;
}
protected registerGetter(language: ScriptingLanguage, getter: Getter<T>) { protected registerGetter(language: ScriptingLanguage, getter: Getter<T>) {
assertInRange(language, ScriptingLanguage); assertInRange(language, ScriptingLanguage);
if (!getter) { if (!getter) {
throw new Error('undefined getter'); throw new Error('undefined getter');
}
if (this.getters.has(language)) {
throw new Error(`${ScriptingLanguage[language]} is already registered`);
}
this.getters.set(language, getter);
} }
if (this.getters.has(language)) {
throw new Error(`${ScriptingLanguage[language]} is already registered`);
}
this.getters.set(language, getter);
}
} }

View File

@@ -1,64 +1,60 @@
import { IApplicationContext, IApplicationContextChangedEvent } from './IApplicationContext';
import { ICategoryCollectionState } from './State/ICategoryCollectionState';
import { CategoryCollectionState } from './State/CategoryCollectionState';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import { assertInRange } from '@/application/Common/Enum'; import { assertInRange } from '@/application/Common/Enum';
import { CategoryCollectionState } from './State/CategoryCollectionState';
import { ICategoryCollectionState } from './State/ICategoryCollectionState';
import { IApplicationContext, IApplicationContextChangedEvent } from './IApplicationContext';
type StateMachine = Map<OperatingSystem, ICategoryCollectionState>; type StateMachine = Map<OperatingSystem, ICategoryCollectionState>;
export class ApplicationContext implements IApplicationContext { export class ApplicationContext implements IApplicationContext {
public readonly contextChanged = new EventSource<IApplicationContextChangedEvent>(); public readonly contextChanged = new EventSource<IApplicationContextChangedEvent>();
public collection: ICategoryCollection;
public currentOs: OperatingSystem;
public collection: ICategoryCollection; public get state(): ICategoryCollectionState {
return this.states[this.collection.os];
public currentOs: OperatingSystem;
public get state(): ICategoryCollectionState {
return this.states[this.collection.os];
}
private readonly states: StateMachine;
public constructor(
public readonly app: IApplication,
initialContext: OperatingSystem,
) {
validateApp(app);
assertInRange(initialContext, OperatingSystem);
this.states = initializeStates(app);
this.changeContext(initialContext);
}
public changeContext(os: OperatingSystem): void {
if (this.currentOs === os) {
return;
} }
this.collection = this.app.getCollection(os);
if (!this.collection) { private readonly states: StateMachine;
throw new Error(`os "${OperatingSystem[os]}" is not defined in application`); public constructor(
public readonly app: IApplication,
initialContext: OperatingSystem) {
validateApp(app);
assertInRange(initialContext, OperatingSystem);
this.states = initializeStates(app);
this.changeContext(initialContext);
}
public changeContext(os: OperatingSystem): void {
if (this.currentOs === os) {
return;
}
this.collection = this.app.getCollection(os);
if (!this.collection) {
throw new Error(`os "${OperatingSystem[os]}" is not defined in application`);
}
const event: IApplicationContextChangedEvent = {
newState: this.states[os],
oldState: this.states[this.currentOs],
};
this.contextChanged.notify(event);
this.currentOs = os;
} }
const event: IApplicationContextChangedEvent = {
newState: this.states[os],
oldState: this.states[this.currentOs],
};
this.contextChanged.notify(event);
this.currentOs = os;
}
} }
function validateApp(app: IApplication) { function validateApp(app: IApplication) {
if (!app) { if (!app) {
throw new Error('undefined app'); throw new Error('undefined app');
} }
} }
function initializeStates(app: IApplication): StateMachine { function initializeStates(app: IApplication): StateMachine {
const machine = new Map<OperatingSystem, ICategoryCollectionState>(); const machine = new Map<OperatingSystem, ICategoryCollectionState>();
for (const collection of app.collections) { for (const collection of app.collections) {
machine[collection.os] = new CategoryCollectionState(collection); machine[collection.os] = new CategoryCollectionState(collection);
} }
return machine; return machine;
} }

View File

@@ -1,32 +1,31 @@
import { ApplicationContext } from './ApplicationContext';
import { IApplicationContext } from '@/application/Context/IApplicationContext'; import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { IApplication } from '@/domain/IApplication';
import { Environment } from '../Environment/Environment'; import { Environment } from '../Environment/Environment';
import { IApplication } from '@/domain/IApplication';
import { IEnvironment } from '../Environment/IEnvironment'; import { IEnvironment } from '../Environment/IEnvironment';
import { IApplicationFactory } from '../IApplicationFactory'; import { IApplicationFactory } from '../IApplicationFactory';
import { ApplicationFactory } from '../ApplicationFactory'; import { ApplicationFactory } from '../ApplicationFactory';
import { ApplicationContext } from './ApplicationContext';
export async function buildContext( export async function buildContext(
factory: IApplicationFactory = ApplicationFactory.Current, factory: IApplicationFactory = ApplicationFactory.Current,
environment = Environment.CurrentEnvironment, environment = Environment.CurrentEnvironment): Promise<IApplicationContext> {
): Promise<IApplicationContext> { if (!factory) { throw new Error('undefined factory'); }
if (!factory) { throw new Error('undefined factory'); } if (!environment) { throw new Error('undefined environment'); }
if (!environment) { throw new Error('undefined environment'); } const app = await factory.getApp();
const app = await factory.getApp(); const os = getInitialOs(app, environment);
const os = getInitialOs(app, environment); return new ApplicationContext(app, os);
return new ApplicationContext(app, os);
} }
function getInitialOs(app: IApplication, environment: IEnvironment): OperatingSystem { function getInitialOs(app: IApplication, environment: IEnvironment): OperatingSystem {
const currentOs = environment.os; const currentOs = environment.os;
const supportedOsList = app.getSupportedOsList(); const supportedOsList = app.getSupportedOsList();
if (supportedOsList.includes(currentOs)) { if (supportedOsList.includes(currentOs)) {
return currentOs; return currentOs;
} }
supportedOsList.sort((os1, os2) => { supportedOsList.sort((os1, os2) => {
const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts; const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts;
return getPriority(os2) - getPriority(os1); return getPriority(os2) - getPriority(os1);
}); });
return supportedOsList[0]; return supportedOsList[0];
} }

View File

@@ -1,20 +1,16 @@
import { ICategoryCollectionState } from './State/ICategoryCollectionState';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { IEventSource } from '@/infrastructure/Events/IEventSource'; import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from './State/ICategoryCollectionState';
export interface IReadOnlyApplicationContext { export interface IApplicationContext {
readonly app: IApplication; readonly app: IApplication;
readonly state: IReadOnlyCategoryCollectionState;
readonly contextChanged: IEventSource<IApplicationContextChangedEvent>;
}
export interface IApplicationContext extends IReadOnlyApplicationContext {
readonly state: ICategoryCollectionState; readonly state: ICategoryCollectionState;
readonly contextChanged: IEventSource<IApplicationContextChangedEvent>;
changeContext(os: OperatingSystem): void; changeContext(os: OperatingSystem): void;
} }
export interface IApplicationContextChangedEvent { export interface IApplicationContextChangedEvent {
readonly newState: ICategoryCollectionState; readonly newState: ICategoryCollectionState;
readonly oldState: ICategoryCollectionState; readonly oldState: ICategoryCollectionState;
} }

View File

@@ -1,5 +1,3 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { UserFilter } from './Filter/UserFilter'; import { UserFilter } from './Filter/UserFilter';
import { IUserFilter } from './Filter/IUserFilter'; import { IUserFilter } from './Filter/IUserFilter';
import { ApplicationCode } from './Code/ApplicationCode'; import { ApplicationCode } from './Code/ApplicationCode';
@@ -7,20 +5,19 @@ import { UserSelection } from './Selection/UserSelection';
import { IUserSelection } from './Selection/IUserSelection'; import { IUserSelection } from './Selection/IUserSelection';
import { ICategoryCollectionState } from './ICategoryCollectionState'; import { ICategoryCollectionState } from './ICategoryCollectionState';
import { IApplicationCode } from './Code/IApplicationCode'; import { IApplicationCode } from './Code/IApplicationCode';
import { ICategoryCollection } from '../../../domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
export class CategoryCollectionState implements ICategoryCollectionState { export class CategoryCollectionState implements ICategoryCollectionState {
public readonly os: OperatingSystem; public readonly os: OperatingSystem;
public readonly code: IApplicationCode;
public readonly selection: IUserSelection;
public readonly filter: IUserFilter;
public readonly code: IApplicationCode; public constructor(readonly collection: ICategoryCollection) {
this.selection = new UserSelection(collection, []);
public readonly selection: IUserSelection; this.code = new ApplicationCode(this.selection, collection.scripting);
this.filter = new UserFilter(collection);
public readonly filter: IUserFilter; this.os = collection.os;
}
public constructor(readonly collection: ICategoryCollection) {
this.selection = new UserSelection(collection, []);
this.code = new ApplicationCode(this.selection, collection.scripting);
this.filter = new UserFilter(collection);
this.os = collection.os;
}
} }

View File

@@ -1,41 +1,39 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IReadOnlyUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { CodeChangedEvent } from './Event/CodeChangedEvent'; import { CodeChangedEvent } from './Event/CodeChangedEvent';
import { CodePosition } from './Position/CodePosition'; import { CodePosition } from './Position/CodePosition';
import { ICodeChangedEvent } from './Event/ICodeChangedEvent'; import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { UserScriptGenerator } from './Generation/UserScriptGenerator'; import { UserScriptGenerator } from './Generation/UserScriptGenerator';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IApplicationCode } from './IApplicationCode'; import { IApplicationCode } from './IApplicationCode';
import { IUserScriptGenerator } from './Generation/IUserScriptGenerator'; import { IUserScriptGenerator } from './Generation/IUserScriptGenerator';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
export class ApplicationCode implements IApplicationCode { export class ApplicationCode implements IApplicationCode {
public readonly changed = new EventSource<ICodeChangedEvent>(); public readonly changed = new EventSource<ICodeChangedEvent>();
public current: string;
public current: string; private scriptPositions = new Map<SelectedScript, CodePosition>();
private scriptPositions = new Map<SelectedScript, CodePosition>(); constructor(
userSelection: IUserSelection,
private readonly scriptingDefinition: IScriptingDefinition,
private readonly generator: IUserScriptGenerator = new UserScriptGenerator()) {
if (!userSelection) { throw new Error('userSelection is null or undefined'); }
if (!scriptingDefinition) { throw new Error('scriptingDefinition is null or undefined'); }
if (!generator) { throw new Error('generator is null or undefined'); }
this.setCode(userSelection.selectedScripts);
userSelection.changed.on((scripts) => {
this.setCode(scripts);
});
}
constructor( private setCode(scripts: ReadonlyArray<SelectedScript>): void {
userSelection: IReadOnlyUserSelection, const oldScripts = Array.from(this.scriptPositions.keys());
private readonly scriptingDefinition: IScriptingDefinition, const code = this.generator.buildCode(scripts, this.scriptingDefinition);
private readonly generator: IUserScriptGenerator = new UserScriptGenerator(), this.current = code.code;
) { this.scriptPositions = code.scriptPositions;
if (!userSelection) { throw new Error('userSelection is null or undefined'); } const event = new CodeChangedEvent(code.code, oldScripts, code.scriptPositions);
if (!scriptingDefinition) { throw new Error('scriptingDefinition is null or undefined'); } this.changed.notify(event);
if (!generator) { throw new Error('generator is null or undefined'); } }
this.setCode(userSelection.selectedScripts);
userSelection.changed.on((scripts) => {
this.setCode(scripts);
});
}
private setCode(scripts: ReadonlyArray<SelectedScript>): void {
const oldScripts = Array.from(this.scriptPositions.keys());
const code = this.generator.buildCode(scripts, this.scriptingDefinition);
this.current = code.code;
this.scriptPositions = code.scriptPositions;
const event = new CodeChangedEvent(code.code, oldScripts, code.scriptPositions);
this.changed.notify(event);
}
} }

View File

@@ -1,71 +1,64 @@
import { ICodeChangedEvent } from './ICodeChangedEvent';
import { SelectedScript } from '../../Selection/SelectedScript';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { SelectedScript } from '../../Selection/SelectedScript';
import { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent { export class CodeChangedEvent implements ICodeChangedEvent {
public readonly code: string; public readonly code: string;
public readonly addedScripts: ReadonlyArray<IScript>;
public readonly removedScripts: ReadonlyArray<IScript>;
public readonly changedScripts: ReadonlyArray<IScript>;
public readonly addedScripts: ReadonlyArray<IScript>; private readonly scripts: Map<IScript, ICodePosition>;
public readonly removedScripts: ReadonlyArray<IScript>; constructor(
code: string,
oldScripts: ReadonlyArray<SelectedScript>,
scripts: Map<SelectedScript, ICodePosition>) {
ensureAllPositionsExist(code, Array.from(scripts.values()));
this.code = code;
const newScripts = Array.from(scripts.keys());
this.addedScripts = selectIfNotExists(newScripts, oldScripts);
this.removedScripts = selectIfNotExists(oldScripts, newScripts);
this.changedScripts = getChangedScripts(oldScripts, newScripts);
this.scripts = new Map<IScript, ICodePosition>();
scripts.forEach((position, selection) => {
this.scripts.set(selection.script, position);
});
}
public readonly changedScripts: ReadonlyArray<IScript>; public isEmpty(): boolean {
return this.scripts.size === 0;
}
private readonly scripts: Map<IScript, ICodePosition>; public getScriptPositionInCode(script: IScript): ICodePosition {
return this.scripts.get(script);
constructor( }
code: string,
oldScripts: ReadonlyArray<SelectedScript>,
scripts: Map<SelectedScript, ICodePosition>,
) {
ensureAllPositionsExist(code, Array.from(scripts.values()));
this.code = code;
const newScripts = Array.from(scripts.keys());
this.addedScripts = selectIfNotExists(newScripts, oldScripts);
this.removedScripts = selectIfNotExists(oldScripts, newScripts);
this.changedScripts = getChangedScripts(oldScripts, newScripts);
this.scripts = new Map<IScript, ICodePosition>();
scripts.forEach((position, selection) => {
this.scripts.set(selection.script, position);
});
}
public isEmpty(): boolean {
return this.scripts.size === 0;
}
public getScriptPositionInCode(script: IScript): ICodePosition {
return this.scripts.get(script);
}
} }
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) { function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = script.split(/\r\n|\r|\n/).length; const totalLines = script.split(/\r\n|\r|\n/).length;
const missingPositions = positions.filter((position) => position.endLine > totalLines); for (const position of positions) {
if (missingPositions.length > 0) { if (position.endLine > totalLines) {
throw new Error( throw new Error(`script end line (${position.endLine}) is out of range.` +
`Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"` `(total code lines: ${totalLines}`);
+ `(total code lines: ${totalLines}).`, }
); }
}
} }
function getChangedScripts( function getChangedScripts(
oldScripts: ReadonlyArray<SelectedScript>, oldScripts: ReadonlyArray<SelectedScript>,
newScripts: ReadonlyArray<SelectedScript>, newScripts: ReadonlyArray<SelectedScript>): ReadonlyArray<IScript> {
): ReadonlyArray<IScript> { return newScripts
return newScripts .filter((newScript) => oldScripts.find((oldScript) => oldScript.id === newScript.id
.filter((newScript) => oldScripts.find((oldScript) => oldScript.id === newScript.id && oldScript.revert !== newScript.revert ))
&& oldScript.revert !== newScript.revert)) .map((selection) => selection.script);
.map((selection) => selection.script);
} }
function selectIfNotExists( function selectIfNotExists(
selectableContainer: ReadonlyArray<SelectedScript>, selectableContainer: ReadonlyArray<SelectedScript>,
test: ReadonlyArray<SelectedScript>, test: ReadonlyArray<SelectedScript>) {
) { return selectableContainer
return selectableContainer .filter((script) => !test.find((oldScript) => oldScript.id === script.id))
.filter((script) => !test.find((oldScript) => oldScript.id === script.id)) .map((selection) => selection.script);
.map((selection) => selection.script);
} }

View File

@@ -2,10 +2,10 @@ import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
export interface ICodeChangedEvent { export interface ICodeChangedEvent {
readonly code: string; readonly code: string;
addedScripts: ReadonlyArray<IScript>; addedScripts: ReadonlyArray<IScript>;
removedScripts: ReadonlyArray<IScript>; removedScripts: ReadonlyArray<IScript>;
changedScripts: ReadonlyArray<IScript>; changedScripts: ReadonlyArray<IScript>;
isEmpty(): boolean; isEmpty(): boolean;
getScriptPositionInCode(script: IScript): ICodePosition; getScriptPositionInCode(script: IScript): ICodePosition;
} }

View File

@@ -4,65 +4,64 @@ const NewLine = '\n';
const TotalFunctionSeparatorChars = 58; const TotalFunctionSeparatorChars = 58;
export abstract class CodeBuilder implements ICodeBuilder { export abstract class CodeBuilder implements ICodeBuilder {
private readonly lines = new Array<string>(); private readonly lines = new Array<string>();
// Returns current line starting from 0 (no lines), or 1 (have single line) // Returns current line starting from 0 (no lines), or 1 (have single line)
public get currentLine(): number { public get currentLine(): number {
return this.lines.length; return this.lines.length;
}
public appendLine(code?: string): CodeBuilder {
if (!code) {
this.lines.push('');
return this;
} }
const lines = code.match(/[^\r\n]+/g);
this.lines.push(...lines);
return this;
}
public appendTrailingHyphensCommentLine( public appendLine(code?: string): CodeBuilder {
totalRepeatHyphens: number = TotalFunctionSeparatorChars, if (!code) {
): CodeBuilder { this.lines.push('');
return this.appendCommentLine('-'.repeat(totalRepeatHyphens)); return this;
} }
const lines = code.match(/[^\r\n]+/g);
public appendCommentLine(commentLine?: string): CodeBuilder { for (const line of lines) {
this.lines.push(`${this.getCommentDelimiter()} ${commentLine}`); this.lines.push(line);
return this; }
} return this;
public appendFunction(name: string, code: string): CodeBuilder {
if (!name) { throw new Error('name cannot be empty or null'); }
if (!code) { throw new Error('code cannot be empty or null'); }
return this
.appendCommentLineWithHyphensAround(name)
.appendLine(this.writeStandardOut(`--- ${name}`))
.appendLine(code)
.appendTrailingHyphensCommentLine();
}
public appendCommentLineWithHyphensAround(
sectionName: string,
totalRepeatHyphens: number = TotalFunctionSeparatorChars,
): CodeBuilder {
if (!sectionName) { throw new Error('sectionName cannot be empty or null'); }
if (sectionName.length >= totalRepeatHyphens) {
return this.appendCommentLine(sectionName);
} }
const firstHyphens = '-'.repeat(Math.floor((totalRepeatHyphens - sectionName.length) / 2));
const secondHyphens = '-'.repeat(Math.ceil((totalRepeatHyphens - sectionName.length) / 2));
return this
.appendTrailingHyphensCommentLine()
.appendCommentLine(firstHyphens + sectionName + secondHyphens)
.appendTrailingHyphensCommentLine(TotalFunctionSeparatorChars);
}
public toString(): string { public appendTrailingHyphensCommentLine(
return this.lines.join(NewLine); totalRepeatHyphens: number = TotalFunctionSeparatorChars): CodeBuilder {
} return this.appendCommentLine('-'.repeat(totalRepeatHyphens));
}
protected abstract getCommentDelimiter(): string; public appendCommentLine(commentLine?: string): CodeBuilder {
this.lines.push(`${this.getCommentDelimiter()} ${commentLine}`);
return this;
}
protected abstract writeStandardOut(text: string): string; public appendFunction(name: string, code: string): CodeBuilder {
if (!name) { throw new Error('name cannot be empty or null'); }
if (!code) { throw new Error('code cannot be empty or null'); }
return this
.appendCommentLineWithHyphensAround(name)
.appendLine(this.writeStandardOut(`--- ${name}`))
.appendLine(code)
.appendTrailingHyphensCommentLine();
}
public appendCommentLineWithHyphensAround(
sectionName: string,
totalRepeatHyphens: number = TotalFunctionSeparatorChars): CodeBuilder {
if (!sectionName) { throw new Error('sectionName cannot be empty or null'); }
if (sectionName.length >= totalRepeatHyphens) {
return this.appendCommentLine(sectionName);
}
const firstHyphens = '-'.repeat(Math.floor((totalRepeatHyphens - sectionName.length) / 2));
const secondHyphens = '-'.repeat(Math.ceil((totalRepeatHyphens - sectionName.length) / 2));
return this
.appendTrailingHyphensCommentLine()
.appendCommentLine(firstHyphens + sectionName + secondHyphens)
.appendTrailingHyphensCommentLine(TotalFunctionSeparatorChars);
}
public toString(): string {
return this.lines.join(NewLine);
}
protected abstract getCommentDelimiter(): string;
protected abstract writeStandardOut(text: string): string;
} }

View File

@@ -5,12 +5,10 @@ import { BatchBuilder } from './Languages/BatchBuilder';
import { ShellBuilder } from './Languages/ShellBuilder'; import { ShellBuilder } from './Languages/ShellBuilder';
import { ICodeBuilderFactory } from './ICodeBuilderFactory'; import { ICodeBuilderFactory } from './ICodeBuilderFactory';
export class CodeBuilderFactory export class CodeBuilderFactory extends ScriptingLanguageFactory<ICodeBuilder> implements ICodeBuilderFactory {
extends ScriptingLanguageFactory<ICodeBuilder> constructor() {
implements ICodeBuilderFactory { super();
constructor() { this.registerGetter(ScriptingLanguage.shellscript, () => new ShellBuilder());
super(); this.registerGetter(ScriptingLanguage.batchfile, () => new BatchBuilder());
this.registerGetter(ScriptingLanguage.shellscript, () => new ShellBuilder()); }
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchBuilder());
}
} }

View File

@@ -1,9 +1,9 @@
export interface ICodeBuilder { export interface ICodeBuilder {
currentLine: number; currentLine: number;
appendLine(code?: string): ICodeBuilder; appendLine(code?: string): ICodeBuilder;
appendTrailingHyphensCommentLine(totalRepeatHyphens: number): ICodeBuilder; appendTrailingHyphensCommentLine(totalRepeatHyphens: number): ICodeBuilder;
appendCommentLine(commentLine?: string): ICodeBuilder; appendCommentLine(commentLine?: string): ICodeBuilder;
appendCommentLineWithHyphensAround(sectionName: string, totalRepeatHyphens: number): ICodeBuilder; appendCommentLineWithHyphensAround(sectionName: string, totalRepeatHyphens: number): ICodeBuilder;
appendFunction(name: string, code: string): ICodeBuilder; appendFunction(name: string, code: string): ICodeBuilder;
toString(): string; toString(): string;
} }

View File

@@ -1,4 +1,5 @@
import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
import { ICodeBuilder } from './ICodeBuilder'; import { ICodeBuilder } from './ICodeBuilder';
import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
export type ICodeBuilderFactory = IScriptingLanguageFactory<ICodeBuilder>; export interface ICodeBuilderFactory extends IScriptingLanguageFactory<ICodeBuilder> {
}

View File

@@ -2,6 +2,6 @@ import { SelectedScript } from '@/application/Context/State/Selection/SelectedSc
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
export interface IUserScript { export interface IUserScript {
code: string; code: string;
scriptPositions: Map<SelectedScript, ICodePosition>; scriptPositions: Map<SelectedScript, ICodePosition>;
} }

View File

@@ -1,9 +1,9 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { IUserScript } from './IUserScript'; import { IUserScript } from './IUserScript';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
export interface IUserScriptGenerator { export interface IUserScriptGenerator {
buildCode( buildCode(
selectedScripts: ReadonlyArray<SelectedScript>, selectedScripts: ReadonlyArray<SelectedScript>,
scriptingDefinition: IScriptingDefinition): IUserScript; scriptingDefinition: IScriptingDefinition): IUserScript;
} }

View File

@@ -1,17 +1,16 @@
import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder';
export class BatchBuilder extends CodeBuilder { export class BatchBuilder extends CodeBuilder {
protected getCommentDelimiter(): string { protected getCommentDelimiter(): string {
return '::'; return '::';
} }
protected writeStandardOut(text: string): string {
protected writeStandardOut(text: string): string { return `echo ${escapeForEcho(text)}`;
return `echo ${escapeForEcho(text)}`; }
}
} }
function escapeForEcho(text: string) { function escapeForEcho(text: string) {
return text return text
.replace(/&/g, '^&') .replace(/&/g, '^&')
.replace(/%/g, '%%'); .replace(/%/g, '%%');
} }

View File

@@ -1,16 +1,15 @@
import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder';
export class ShellBuilder extends CodeBuilder { export class ShellBuilder extends CodeBuilder {
protected getCommentDelimiter(): string { protected getCommentDelimiter(): string {
return '#'; return '#';
} }
protected writeStandardOut(text: string): string {
protected writeStandardOut(text: string): string { return `echo '${escapeForEcho(text)}'`;
return `echo '${escapeForEcho(text)}'`; }
}
} }
function escapeForEcho(text: string) { function escapeForEcho(text: string) {
return text return text
.replace(/'/g, '\'\\\'\''); .replace(/'/g, '\'\\\'\'');
} }

View File

@@ -1,75 +1,71 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { CodePosition } from '../Position/CodePosition';
import { IUserScriptGenerator } from './IUserScriptGenerator'; import { IUserScriptGenerator } from './IUserScriptGenerator';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { CodePosition } from '../Position/CodePosition';
import { IUserScript } from './IUserScript'; import { IUserScript } from './IUserScript';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ICodeBuilder } from './ICodeBuilder'; import { ICodeBuilder } from './ICodeBuilder';
import { ICodeBuilderFactory } from './ICodeBuilderFactory'; import { ICodeBuilderFactory } from './ICodeBuilderFactory';
import { CodeBuilderFactory } from './CodeBuilderFactory'; import { CodeBuilderFactory } from './CodeBuilderFactory';
export class UserScriptGenerator implements IUserScriptGenerator { export class UserScriptGenerator implements IUserScriptGenerator {
constructor(private readonly codeBuilderFactory: ICodeBuilderFactory = new CodeBuilderFactory()) { constructor(private readonly codeBuilderFactory: ICodeBuilderFactory = new CodeBuilderFactory()) {
}
public buildCode(
selectedScripts: ReadonlyArray<SelectedScript>,
scriptingDefinition: IScriptingDefinition,
): IUserScript {
if (!selectedScripts) { throw new Error('undefined scripts'); }
if (!scriptingDefinition) { throw new Error('undefined definition'); }
if (!selectedScripts.length) {
return { code: '', scriptPositions: new Map<SelectedScript, ICodePosition>() };
} }
let builder = this.codeBuilderFactory.create(scriptingDefinition.language); public buildCode(
builder = initializeCode(scriptingDefinition.startCode, builder); selectedScripts: ReadonlyArray<SelectedScript>,
const scriptPositions = selectedScripts.reduce((result, selection) => { scriptingDefinition: IScriptingDefinition): IUserScript {
return appendSelection(selection, result, builder); if (!selectedScripts) { throw new Error('undefined scripts'); }
}, new Map<SelectedScript, ICodePosition>()); if (!scriptingDefinition) { throw new Error('undefined definition'); }
const code = finalizeCode(builder, scriptingDefinition.endCode); let scriptPositions = new Map<SelectedScript, ICodePosition>();
return { code, scriptPositions }; if (!selectedScripts.length) {
} return { code: '', scriptPositions };
}
let builder = this.codeBuilderFactory.create(scriptingDefinition.language);
builder = initializeCode(scriptingDefinition.startCode, builder);
for (const selection of selectedScripts) {
scriptPositions = appendSelection(selection, scriptPositions, builder);
}
const code = finalizeCode(builder, scriptingDefinition.endCode);
return { code, scriptPositions };
}
} }
function initializeCode(startCode: string, builder: ICodeBuilder): ICodeBuilder { function initializeCode(startCode: string, builder: ICodeBuilder): ICodeBuilder {
if (!startCode) { if (!startCode) {
return builder; return builder;
} }
return builder return builder
.appendLine(startCode) .appendLine(startCode)
.appendLine(); .appendLine();
} }
function finalizeCode(builder: ICodeBuilder, endCode: string): string { function finalizeCode(builder: ICodeBuilder, endCode: string): string {
if (!endCode) { if (!endCode) {
return builder.toString(); return builder.toString();
} }
return builder.appendLine() return builder.appendLine()
.appendLine(endCode) .appendLine(endCode)
.toString(); .toString();
} }
function appendSelection( function appendSelection(
selection: SelectedScript, selection: SelectedScript,
scriptPositions: Map<SelectedScript, ICodePosition>, scriptPositions: Map<SelectedScript, ICodePosition>,
builder: ICodeBuilder, builder: ICodeBuilder): Map<SelectedScript, ICodePosition> {
): Map<SelectedScript, ICodePosition> { const startPosition = builder.currentLine + 1; // Because first line will be empty to separate scripts
// Start from next line because first line will be empty to separate scripts builder = appendCode(selection, builder);
const startPosition = builder.currentLine + 1; const endPosition = builder.currentLine - 1;
appendCode(selection, builder); builder.appendLine();
const endPosition = builder.currentLine - 1; const position = new CodePosition(startPosition, endPosition);
builder.appendLine(); scriptPositions.set(selection, position);
const position = new CodePosition(startPosition, endPosition); return scriptPositions;
scriptPositions.set(selection, position);
return scriptPositions;
} }
function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder { function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder {
const { script } = selection; const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name;
const name = selection.revert ? `${script.name} (revert)` : script.name; const scriptCode = selection.revert ? selection.script.code.revert : selection.script.code.execute;
const scriptCode = selection.revert ? script.code.revert : script.code.execute; return builder
return builder .appendLine()
.appendLine() .appendFunction(name, scriptCode);
.appendFunction(name, scriptCode);
} }

View File

@@ -1,7 +1,7 @@
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { ICodeChangedEvent } from './Event/ICodeChangedEvent'; import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
import { IEventSource } from '@/infrastructure/Events/IEventSource';
export interface IApplicationCode { export interface IApplicationCode {
readonly changed: IEventSource<ICodeChangedEvent>; readonly changed: IEventSource<ICodeChangedEvent>;
readonly current: string; readonly current: string;
} }

View File

@@ -1,25 +1,24 @@
import { ICodePosition } from './ICodePosition'; import { ICodePosition } from './ICodePosition';
export class CodePosition implements ICodePosition { export class CodePosition implements ICodePosition {
public get totalLines(): number { public get totalLines(): number {
return this.endLine - this.startLine; return this.endLine - this.startLine;
} }
constructor( constructor(
public readonly startLine: number, public readonly startLine: number,
public readonly endLine: number, public readonly endLine: number) {
) { if (startLine < 0) {
if (startLine < 0) { throw new Error('Code cannot start in a negative line');
throw new Error('Code cannot start in a negative line'); }
if (endLine < 0) {
throw new Error('Code cannot end in a negative line');
}
if (endLine === startLine) {
throw new Error('Empty code');
}
if (endLine < startLine) {
throw new Error('End line cannot be less than start line');
}
} }
if (endLine < 0) {
throw new Error('Code cannot end in a negative line');
}
if (endLine === startLine) {
throw new Error('Empty code');
}
if (endLine < startLine) {
throw new Error('End line cannot be less than start line');
}
}
} }

View File

@@ -1,5 +1,5 @@
export interface ICodePosition { export interface ICodePosition {
readonly startLine: number; readonly startLine: number;
readonly endLine: number; readonly endLine: number;
readonly totalLines: number; readonly totalLines: number;
} }

View File

@@ -1,20 +1,18 @@
import { IFilterResult } from './IFilterResult';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory'; import { ICategory } from '@/domain/ICategory';
import { IFilterResult } from './IFilterResult';
export class FilterResult implements IFilterResult { export class FilterResult implements IFilterResult {
constructor( constructor(
public readonly scriptMatches: ReadonlyArray<IScript>, public readonly scriptMatches: ReadonlyArray<IScript>,
public readonly categoryMatches: ReadonlyArray<ICategory>, public readonly categoryMatches: ReadonlyArray<ICategory>,
public readonly query: string, public readonly query: string) {
) { if (!query) { throw new Error('Query is empty or undefined'); }
if (!query) { throw new Error('Query is empty or undefined'); } if (!scriptMatches) { throw new Error('Script matches is undefined'); }
if (!scriptMatches) { throw new Error('Script matches is undefined'); } if (!categoryMatches) { throw new Error('Category matches is undefined'); }
if (!categoryMatches) { throw new Error('Category matches is undefined'); } }
} public hasAnyMatches(): boolean {
return this.scriptMatches.length > 0
public hasAnyMatches(): boolean { || this.categoryMatches.length > 0;
return this.scriptMatches.length > 0 }
|| this.categoryMatches.length > 0;
}
} }

View File

@@ -1,8 +1,8 @@
import { IScript, ICategory } from '@/domain/ICategory'; import { IScript, ICategory } from '@/domain/ICategory';
export interface IFilterResult { export interface IFilterResult {
readonly categoryMatches: ReadonlyArray<ICategory>; readonly categoryMatches: ReadonlyArray<ICategory>;
readonly scriptMatches: ReadonlyArray<IScript>; readonly scriptMatches: ReadonlyArray<IScript>;
readonly query: string; readonly query: string;
hasAnyMatches(): boolean; hasAnyMatches(): boolean;
} }

View File

@@ -1,13 +1,10 @@
import { IEventSource } from '@/infrastructure/Events/IEventSource'; import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { IFilterResult } from './IFilterResult'; import { IFilterResult } from './IFilterResult';
export interface IReadOnlyUserFilter { export interface IUserFilter {
readonly currentFilter: IFilterResult | undefined; readonly currentFilter: IFilterResult | undefined;
readonly filtered: IEventSource<IFilterResult>; readonly filtered: IEventSource<IFilterResult>;
readonly filterRemoved: IEventSource<void>; readonly filterRemoved: IEventSource<void>;
} setFilter(filter: string): void;
removeFilter(): void;
export interface IUserFilter extends IReadOnlyUserFilter {
setFilter(filter: string): void;
removeFilter(): void;
} }

View File

@@ -1,56 +1,52 @@
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { FilterResult } from './FilterResult'; import { FilterResult } from './FilterResult';
import { IFilterResult } from './IFilterResult'; import { IFilterResult } from './IFilterResult';
import { IUserFilter } from './IUserFilter'; import { IUserFilter } from './IUserFilter';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
export class UserFilter implements IUserFilter { export class UserFilter implements IUserFilter {
public readonly filtered = new EventSource<IFilterResult>(); public readonly filtered = new EventSource<IFilterResult>();
public readonly filterRemoved = new EventSource<void>();
public currentFilter: IFilterResult | undefined;
public readonly filterRemoved = new EventSource<void>(); constructor(private collection: ICategoryCollection) {
public currentFilter: IFilterResult | undefined;
constructor(private collection: ICategoryCollection) {
}
public setFilter(filter: string): void {
if (!filter) {
throw new Error('Filter must be defined and not empty. Use removeFilter() to remove the filter');
} }
const filterLowercase = filter.toLocaleLowerCase();
const filteredScripts = this.collection.getAllScripts().filter(
(script) => isScriptAMatch(script, filterLowercase),
);
const filteredCategories = this.collection.getAllCategories().filter(
(category) => category.name.toLowerCase().includes(filterLowercase),
);
const matches = new FilterResult(
filteredScripts,
filteredCategories,
filter,
);
this.currentFilter = matches;
this.filtered.notify(matches);
}
public removeFilter(): void { public setFilter(filter: string): void {
this.currentFilter = undefined; if (!filter) {
this.filterRemoved.notify(); throw new Error('Filter must be defined and not empty. Use removeFilter() to remove the filter');
} }
const filterLowercase = filter.toLocaleLowerCase();
const filteredScripts = this.collection.getAllScripts().filter(
(script) => isScriptAMatch(script, filterLowercase));
const filteredCategories = this.collection.getAllCategories().filter(
(category) => category.name.toLowerCase().includes(filterLowercase));
const matches = new FilterResult(
filteredScripts,
filteredCategories,
filter,
);
this.currentFilter = matches;
this.filtered.notify(matches);
}
public removeFilter(): void {
this.currentFilter = undefined;
this.filterRemoved.notify();
}
} }
function isScriptAMatch(script: IScript, filterLowercase: string) { function isScriptAMatch(script: IScript, filterLowercase: string) {
if (script.name.toLowerCase().includes(filterLowercase)) { if (script.name.toLowerCase().includes(filterLowercase)) {
return true; return true;
} }
if (script.code.execute.toLowerCase().includes(filterLowercase)) { if (script.code.execute.toLowerCase().includes(filterLowercase)) {
return true; return true;
} }
if (script.code.revert) { if (script.code.revert) {
return script.code.revert.toLowerCase().includes(filterLowercase); return script.code.revert.toLowerCase().includes(filterLowercase);
} }
return false; return false;
} }

View File

@@ -1,18 +1,13 @@
import { IUserFilter } from './Filter/IUserFilter';
import { IUserSelection } from './Selection/IUserSelection';
import { IApplicationCode } from './Code/IApplicationCode';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { IReadOnlyUserFilter, IUserFilter } from './Filter/IUserFilter';
import { IReadOnlyUserSelection, IUserSelection } from './Selection/IUserSelection';
import { IApplicationCode } from './Code/IApplicationCode';
export interface IReadOnlyCategoryCollectionState { export interface ICategoryCollectionState {
readonly code: IApplicationCode; readonly code: IApplicationCode;
readonly os: OperatingSystem; readonly filter: IUserFilter;
readonly filter: IReadOnlyUserFilter; readonly selection: IUserSelection;
readonly selection: IReadOnlyUserSelection; readonly collection: ICategoryCollection;
readonly collection: ICategoryCollection; readonly os: OperatingSystem;
}
export interface ICategoryCollectionState extends IReadOnlyCategoryCollectionState {
readonly filter: IUserFilter;
readonly selection: IUserSelection;
} }

View File

@@ -1,23 +1,20 @@
import { SelectedScript } from './SelectedScript';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory'; import { ICategory } from '@/domain/ICategory';
import { IEventSource } from '@/infrastructure/Events/IEventSource'; import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { SelectedScript } from './SelectedScript';
export interface IReadOnlyUserSelection { export interface IUserSelection {
readonly changed: IEventSource<ReadonlyArray<SelectedScript>>; readonly changed: IEventSource<ReadonlyArray<SelectedScript>>;
readonly selectedScripts: ReadonlyArray<SelectedScript>; readonly selectedScripts: ReadonlyArray<SelectedScript>;
isSelected(scriptId: string): boolean; areAllSelected(category: ICategory): boolean;
areAllSelected(category: ICategory): boolean; isAnySelected(category: ICategory): boolean;
isAnySelected(category: ICategory): boolean; removeAllInCategory(categoryId: number): void;
} addOrUpdateAllInCategory(categoryId: number, revert: boolean): void;
addSelectedScript(scriptId: string, revert: boolean): void;
export interface IUserSelection extends IReadOnlyUserSelection { addOrUpdateSelectedScript(scriptId: string, revert: boolean): void;
removeAllInCategory(categoryId: number): void; removeSelectedScript(scriptId: string): void;
addOrUpdateAllInCategory(categoryId: number, revert: boolean): void; selectOnly(scripts: ReadonlyArray<IScript>): void;
addSelectedScript(scriptId: string, revert: boolean): void; isSelected(scriptId: string): boolean;
addOrUpdateSelectedScript(scriptId: string, revert: boolean): void; selectAll(): void;
removeSelectedScript(scriptId: string): void; deselectAll(): void;
selectOnly(scripts: ReadonlyArray<IScript>): void;
selectAll(): void;
deselectAll(): void;
} }

View File

@@ -2,13 +2,13 @@ import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
export class SelectedScript extends BaseEntity<string> { export class SelectedScript extends BaseEntity<string> {
constructor( constructor(
public readonly script: IScript, public readonly script: IScript,
public readonly revert: boolean, public readonly revert: boolean,
) { ) {
super(script.id); super(script.id);
if (revert && !script.canRevert()) { if (revert && !script.canRevert()) {
throw new Error('cannot revert an irreversible script'); throw new Error('cannot revert an irreversible script');
}
} }
}
} }

View File

@@ -1,146 +1,141 @@
import { SelectedScript } from './SelectedScript';
import { IUserSelection } from './IUserSelection';
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository'; import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import { IRepository } from '@/infrastructure/Repository/IRepository'; import { IRepository } from '@/infrastructure/Repository/IRepository';
import { ICategory } from '@/domain/ICategory'; import { ICategory } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { IUserSelection } from './IUserSelection';
import { SelectedScript } from './SelectedScript';
export class UserSelection implements IUserSelection { export class UserSelection implements IUserSelection {
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>(); public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
private readonly scripts: IRepository<string, SelectedScript>;
private readonly scripts: IRepository<string, SelectedScript>; constructor(
private readonly collection: ICategoryCollection,
selectedScripts: ReadonlyArray<SelectedScript>) {
this.scripts = new InMemoryRepository<string, SelectedScript>();
if (selectedScripts && selectedScripts.length > 0) {
for (const script of selectedScripts) {
this.scripts.addItem(script);
}
}
}
constructor( public areAllSelected(category: ICategory): boolean {
private readonly collection: ICategoryCollection, if (this.selectedScripts.length === 0) {
selectedScripts: ReadonlyArray<SelectedScript>, return false;
) { }
this.scripts = new InMemoryRepository<string, SelectedScript>(); const scripts = category.getAllScriptsRecursively();
for (const script of selectedScripts) { if (this.selectedScripts.length < scripts.length) {
this.scripts.addItem(script); return false;
}
return scripts.every((script) => this.selectedScripts.some((selected) => selected.id === script.id));
} }
}
public areAllSelected(category: ICategory): boolean { public isAnySelected(category: ICategory): boolean {
if (this.selectedScripts.length === 0) { if (this.selectedScripts.length === 0) {
return false; return false;
}
return this.selectedScripts.some((s) => category.includes(s.script));
} }
const scripts = category.getAllScriptsRecursively();
if (this.selectedScripts.length < scripts.length) {
return false;
}
return scripts.every(
(script) => this.selectedScripts.some((selected) => selected.id === script.id),
);
}
public isAnySelected(category: ICategory): boolean { public removeAllInCategory(categoryId: number): void {
if (this.selectedScripts.length === 0) { const category = this.collection.findCategory(categoryId);
return false; const scriptsToRemove = category.getAllScriptsRecursively()
.filter((script) => this.scripts.exists(script.id));
if (!scriptsToRemove.length) {
return;
}
for (const script of scriptsToRemove) {
this.scripts.removeItem(script.id);
}
this.changed.notify(this.scripts.getItems());
} }
return this.selectedScripts.some((s) => category.includes(s.script));
}
public removeAllInCategory(categoryId: number): void { public addOrUpdateAllInCategory(categoryId: number, revert: boolean = false): void {
const category = this.collection.findCategory(categoryId); const category = this.collection.findCategory(categoryId);
const scriptsToRemove = category.getAllScriptsRecursively() const scriptsToAddOrUpdate = category.getAllScriptsRecursively()
.filter((script) => this.scripts.exists(script.id)); .filter((script) =>
if (!scriptsToRemove.length) { !this.scripts.exists(script.id)
return; || this.scripts.getById(script.id).revert !== revert,
);
if (!scriptsToAddOrUpdate.length) {
return;
}
for (const script of scriptsToAddOrUpdate) {
const selectedScript = new SelectedScript(script, revert);
this.scripts.addOrUpdateItem(selectedScript);
}
this.changed.notify(this.scripts.getItems());
} }
for (const script of scriptsToRemove) {
this.scripts.removeItem(script.id);
}
this.changed.notify(this.scripts.getItems());
}
public addOrUpdateAllInCategory(categoryId: number, revert = false): void { public addSelectedScript(scriptId: string, revert: boolean): void {
const scriptsToAddOrUpdate = this.collection const script = this.collection.findScript(scriptId);
.findCategory(categoryId) if (!script) {
.getAllScriptsRecursively() throw new Error(`Cannot add (id: ${scriptId}) as it is unknown`);
.filter( }
(script) => !this.scripts.exists(script.id) const selectedScript = new SelectedScript(script, revert);
|| this.scripts.getById(script.id).revert !== revert, this.scripts.addItem(selectedScript);
) this.changed.notify(this.scripts.getItems());
.map((script) => new SelectedScript(script, revert));
if (!scriptsToAddOrUpdate.length) {
return;
} }
for (const script of scriptsToAddOrUpdate) {
this.scripts.addOrUpdateItem(script); public addOrUpdateSelectedScript(scriptId: string, revert: boolean): void {
const script = this.collection.findScript(scriptId);
const selectedScript = new SelectedScript(script, revert);
this.scripts.addOrUpdateItem(selectedScript);
this.changed.notify(this.scripts.getItems());
} }
this.changed.notify(this.scripts.getItems());
}
public addSelectedScript(scriptId: string, revert: boolean): void { public removeSelectedScript(scriptId: string): void {
const script = this.collection.findScript(scriptId); this.scripts.removeItem(scriptId);
if (!script) { this.changed.notify(this.scripts.getItems());
throw new Error(`Cannot add (id: ${scriptId}) as it is unknown`);
} }
const selectedScript = new SelectedScript(script, revert);
this.scripts.addItem(selectedScript);
this.changed.notify(this.scripts.getItems());
}
public addOrUpdateSelectedScript(scriptId: string, revert: boolean): void { public isSelected(scriptId: string): boolean {
const script = this.collection.findScript(scriptId); return this.scripts.exists(scriptId);
const selectedScript = new SelectedScript(script, revert);
this.scripts.addOrUpdateItem(selectedScript);
this.changed.notify(this.scripts.getItems());
}
public removeSelectedScript(scriptId: string): void {
this.scripts.removeItem(scriptId);
this.changed.notify(this.scripts.getItems());
}
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
/** Get users scripts based on his/her selections */
public get selectedScripts(): ReadonlyArray<SelectedScript> {
return this.scripts.getItems();
}
public selectAll(): void {
const scriptsToSelect = this.collection
.getAllScripts()
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new SelectedScript(script, false));
for (const script of scriptsToSelect) {
this.scripts.addItem(script);
} }
this.changed.notify(this.scripts.getItems());
}
public deselectAll(): void { /** Get users scripts based on his/her selections */
const selectedScriptIds = this.scripts.getItems().map((script) => script.id); public get selectedScripts(): ReadonlyArray<SelectedScript> {
for (const scriptId of selectedScriptIds) { return this.scripts.getItems();
this.scripts.removeItem(scriptId);
} }
this.changed.notify([]);
}
public selectOnly(scripts: readonly IScript[]): void { public selectAll(): void {
if (!scripts || scripts.length === 0) { for (const script of this.collection.getAllScripts()) {
throw new Error('Scripts are empty. Use deselectAll() if you want to deselect everything'); if (!this.scripts.exists(script.id)) {
const selection = new SelectedScript(script, false);
this.scripts.addItem(selection);
}
}
this.changed.notify(this.scripts.getItems());
} }
// Unselect from selected scripts
if (this.scripts.length !== 0) { public deselectAll(): void {
this.scripts.getItems() const selectedScriptIds = this.scripts.getItems().map((script) => script.id);
.filter((existing) => !scripts.some((script) => existing.id === script.id)) for (const scriptId of selectedScriptIds) {
.map((script) => script.id) this.scripts.removeItem(scriptId);
.forEach((scriptId) => this.scripts.removeItem(scriptId)); }
this.changed.notify([]);
} }
// Select from unselected scripts
const unselectedScripts = scripts public selectOnly(scripts: readonly IScript[]): void {
.filter((script) => !this.scripts.exists(script.id)) if (!scripts || scripts.length === 0) {
.map((script) => new SelectedScript(script, false)); throw new Error('Scripts are empty. Use deselectAll() if you want to deselect everything');
for (const toSelect of unselectedScripts) { }
this.scripts.addItem(toSelect); // Unselect from selected scripts
if (this.scripts.length !== 0) {
this.scripts.getItems()
.filter((existing) => !scripts.some((script) => existing.id === script.id))
.map((script) => script.id)
.forEach((scriptId) => this.scripts.removeItem(scriptId));
}
// Select from unselected scripts
const unselectedScripts = scripts.filter((script) => !this.scripts.exists(script.id));
for (const toSelect of unselectedScripts) {
const selection = new SelectedScript(toSelect, false);
this.scripts.addItem(selection);
}
this.changed.notify(this.scripts.getItems());
} }
this.changed.notify(this.scripts.getItems());
}
} }

View File

@@ -3,55 +3,52 @@ import { DetectorBuilder } from './DetectorBuilder';
import { IBrowserOsDetector } from './IBrowserOsDetector'; import { IBrowserOsDetector } from './IBrowserOsDetector';
export class BrowserOsDetector implements IBrowserOsDetector { export class BrowserOsDetector implements IBrowserOsDetector {
private readonly detectors = BrowserDetectors; private readonly detectors = BrowserDetectors;
public detect(userAgent: string): OperatingSystem | undefined {
public detect(userAgent: string): OperatingSystem | undefined { if (!userAgent) {
if (!userAgent) { return undefined;
return undefined; }
for (const detector of this.detectors) {
const os = detector.detect(userAgent);
if (os !== undefined) {
return os;
}
}
return undefined;
} }
for (const detector of this.detectors) {
const os = detector.detect(userAgent);
if (os !== undefined) {
return os;
}
}
return undefined;
}
} }
// Reference: https://github.com/keithws/browser-report/blob/master/index.js#L304 // Reference: https://github.com/keithws/browser-report/blob/master/index.js#L304
const BrowserDetectors = [ const BrowserDetectors =
define(OperatingSystem.KaiOS, (b) => b [
.mustInclude('KAIOS')), define(OperatingSystem.KaiOS, (b) =>
define(OperatingSystem.ChromeOS, (b) => b b.mustInclude('KAIOS')),
.mustInclude('CrOS')), define(OperatingSystem.ChromeOS, (b) =>
define(OperatingSystem.BlackBerryOS, (b) => b b.mustInclude('CrOS')),
.mustInclude('BlackBerry')), define(OperatingSystem.BlackBerryOS, (b) =>
define(OperatingSystem.BlackBerryTabletOS, (b) => b b.mustInclude('BlackBerry')),
.mustInclude('RIM Tablet OS')), define(OperatingSystem.BlackBerryTabletOS, (b) =>
define(OperatingSystem.BlackBerry, (b) => b b.mustInclude('RIM Tablet OS')),
.mustInclude('BB10')), define(OperatingSystem.BlackBerry, (b) =>
define(OperatingSystem.Android, (b) => b b.mustInclude('BB10')),
.mustInclude('Android').mustNotInclude('Windows Phone')), define(OperatingSystem.Android, (b) =>
define(OperatingSystem.Android, (b) => b b.mustInclude('Android').mustNotInclude('Windows Phone')),
.mustInclude('Adr').mustNotInclude('Windows Phone')), define(OperatingSystem.Android, (b) =>
define(OperatingSystem.iOS, (b) => b b.mustInclude('Adr').mustNotInclude('Windows Phone')),
.mustInclude('like Mac OS X')), define(OperatingSystem.iOS, (b) =>
define(OperatingSystem.Linux, (b) => b b.mustInclude('like Mac OS X')),
.mustInclude('Linux').mustNotInclude('Android').mustNotInclude('Adr')), define(OperatingSystem.Linux, (b) =>
define(OperatingSystem.Windows, (b) => b b.mustInclude('Linux').mustNotInclude('Android').mustNotInclude('Adr')),
.mustInclude('Windows').mustNotInclude('Windows Phone')), define(OperatingSystem.Windows, (b) =>
define(OperatingSystem.WindowsPhone, (b) => b b.mustInclude('Windows').mustNotInclude('Windows Phone')),
.mustInclude('Windows Phone')), define(OperatingSystem.WindowsPhone, (b) =>
define(OperatingSystem.macOS, (b) => b b.mustInclude('Windows Phone')),
.mustInclude('OS X').mustNotInclude('Android').mustNotInclude('like Mac OS X')), define(OperatingSystem.macOS, (b) =>
b.mustInclude('OS X').mustNotInclude('Android').mustNotInclude('like Mac OS X')),
]; ];
function define( function define(os: OperatingSystem, applyRules: (builder: DetectorBuilder) => DetectorBuilder): IBrowserOsDetector {
os: OperatingSystem, const builder = new DetectorBuilder(os);
applyRules: (builder: DetectorBuilder) => DetectorBuilder, applyRules(builder);
): IBrowserOsDetector { return builder.build();
const builder = new DetectorBuilder(os);
applyRules(builder);
return builder.build();
} }

View File

@@ -1,54 +1,53 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IBrowserOsDetector } from './IBrowserOsDetector'; import { IBrowserOsDetector } from './IBrowserOsDetector';
import { OperatingSystem } from '@/domain/OperatingSystem';
export class DetectorBuilder { export class DetectorBuilder {
private readonly existingPartsInUserAgent = new Array<string>(); private readonly existingPartsInUserAgent = new Array<string>();
private readonly notExistingPartsInUserAgent = new Array<string>();
private readonly notExistingPartsInUserAgent = new Array<string>(); constructor(private readonly os: OperatingSystem) { }
constructor(private readonly os: OperatingSystem) { } public mustInclude(str: string): DetectorBuilder {
return this.add(str, this.existingPartsInUserAgent);
}
public mustInclude(str: string): DetectorBuilder { public mustNotInclude(str: string): DetectorBuilder {
return this.add(str, this.existingPartsInUserAgent); return this.add(str, this.notExistingPartsInUserAgent);
} }
public mustNotInclude(str: string): DetectorBuilder { public build(): IBrowserOsDetector {
return this.add(str, this.notExistingPartsInUserAgent); if (!this.existingPartsInUserAgent.length) {
} throw new Error('Must include at least a part');
}
return {
detect: (agent) => this.detect(agent),
};
}
public build(): IBrowserOsDetector { private detect(userAgent: string): OperatingSystem {
if (!this.existingPartsInUserAgent.length) { if (!userAgent) {
throw new Error('Must include at least a part'); throw new Error('User agent is null or undefined');
}
if (this.existingPartsInUserAgent.some((part) => !userAgent.includes(part))) {
return undefined;
}
if (this.notExistingPartsInUserAgent.some((part) => userAgent.includes(part))) {
return undefined;
}
return this.os;
} }
return {
detect: (agent) => this.detect(agent),
};
}
private detect(userAgent: string): OperatingSystem { private add(part: string, array: string[]): DetectorBuilder {
if (!userAgent) { if (!part) {
throw new Error('User agent is null or undefined'); throw new Error('part is empty or undefined');
}
if (this.existingPartsInUserAgent.includes(part)) {
throw new Error(`part ${part} is already included as existing part`);
}
if (this.notExistingPartsInUserAgent.includes(part)) {
throw new Error(`part ${part} is already included as not existing part`);
}
array.push(part);
return this;
} }
if (this.existingPartsInUserAgent.some((part) => !userAgent.includes(part))) {
return undefined;
}
if (this.notExistingPartsInUserAgent.some((part) => userAgent.includes(part))) {
return undefined;
}
return this.os;
}
private add(part: string, array: string[]): DetectorBuilder {
if (!part) {
throw new Error('part is empty or undefined');
}
if (this.existingPartsInUserAgent.includes(part)) {
throw new Error(`part ${part} is already included as existing part`);
}
if (this.notExistingPartsInUserAgent.includes(part)) {
throw new Error(`part ${part} is already included as not existing part`);
}
array.push(part);
return this;
}
} }

View File

@@ -1,5 +1,5 @@
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IBrowserOsDetector { export interface IBrowserOsDetector {
detect(userAgent: string): OperatingSystem | undefined; detect(userAgent: string): OperatingSystem | undefined;
} }

View File

@@ -1,89 +1,80 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector'; import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector'; import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector';
import { IEnvironment } from './IEnvironment'; import { IEnvironment } from './IEnvironment';
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IEnvironmentVariables { interface IEnvironmentVariables {
readonly window: Window & typeof globalThis; readonly window: Window & typeof globalThis;
readonly process: NodeJS.Process; readonly process: NodeJS.Process;
readonly navigator: Navigator; readonly navigator: Navigator;
} }
export class Environment implements IEnvironment { export class Environment implements IEnvironment {
public static readonly CurrentEnvironment: IEnvironment = new Environment({ public static readonly CurrentEnvironment: IEnvironment = new Environment({
window, window,
process: typeof process !== 'undefined' ? process /* electron only */ : undefined, process,
navigator, navigator,
}); });
public readonly isDesktop: boolean;
public readonly isDesktop: boolean; public readonly os: OperatingSystem;
protected constructor(
public readonly os: OperatingSystem; variables: IEnvironmentVariables,
browserOsDetector: IBrowserOsDetector = new BrowserOsDetector()) {
protected constructor( if (!variables) {
variables: IEnvironmentVariables, throw new Error('variables is null or empty');
browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(), }
) { this.isDesktop = isDesktop(variables);
if (!variables) { this.os = this.isDesktop ?
throw new Error('variables is null or empty'); getDesktopOsType(getProcessPlatform(variables))
: browserOsDetector.detect(getUserAgent(variables));
} }
this.isDesktop = isDesktop(variables);
if (this.isDesktop) {
this.os = getDesktopOsType(getProcessPlatform(variables));
} else {
const userAgent = getUserAgent(variables);
this.os = !userAgent ? undefined : browserOsDetector.detect(userAgent);
}
}
} }
function getUserAgent(variables: IEnvironmentVariables): string { function getUserAgent(variables: IEnvironmentVariables): string {
if (!variables.window || !variables.window.navigator) { if (!variables.window || !variables.window.navigator) {
return undefined; return undefined;
} }
return variables.window.navigator.userAgent; return variables.window.navigator.userAgent;
} }
function getProcessPlatform(variables: IEnvironmentVariables): string { function getProcessPlatform(variables: IEnvironmentVariables): string {
if (!variables.process || !variables.process.platform) { if (!variables.process || !variables.process.platform) {
return undefined; return undefined;
} }
return variables.process.platform; return variables.process.platform;
} }
function getDesktopOsType(processPlatform: string): OperatingSystem | undefined { function getDesktopOsType(processPlatform: string): OperatingSystem | undefined {
// https://nodejs.org/api/process.html#process_process_platform // https://nodejs.org/api/process.html#process_process_platform
switch (processPlatform) { if (processPlatform === 'darwin') {
case 'darwin': return OperatingSystem.macOS;
return OperatingSystem.macOS; } else if (processPlatform === 'win32') {
case 'win32': return OperatingSystem.Windows;
return OperatingSystem.Windows; } else if (processPlatform === 'linux') {
case 'linux': return OperatingSystem.Linux;
return OperatingSystem.Linux; }
default: return undefined;
return undefined;
}
} }
function isDesktop(variables: IEnvironmentVariables): boolean { function isDesktop(variables: IEnvironmentVariables): boolean {
// More: https://github.com/electron/electron/issues/2288 // More: https://github.com/electron/electron/issues/2288
// Renderer process // Renderer process
if (variables.window if (variables.window
&& variables.window.process && variables.window.process
&& variables.window.process.type === 'renderer') { && variables.window.process.type === 'renderer') {
return true; return true;
} }
// Main process // Main process
if (variables.process if (variables.process
&& variables.process.versions && variables.process.versions
&& Boolean(variables.process.versions.electron)) { && Boolean(variables.process.versions.electron)) {
return true; return true;
} }
// Detect the user agent when the `nodeIntegration` option is set to true // Detect the user agent when the `nodeIntegration` option is set to true
if (variables.navigator if (variables.navigator
&& variables.navigator.userAgent && variables.navigator.userAgent
&& variables.navigator.userAgent.includes('Electron')) { && variables.navigator.userAgent.includes('Electron')) {
return true; return true;
} }
return false; return false;
} }

View File

@@ -1,6 +1,6 @@
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IEnvironment { export interface IEnvironment {
readonly isDesktop: boolean; readonly isDesktop: boolean;
readonly os: OperatingSystem; readonly os: OperatingSystem;
} }

View File

@@ -1,5 +1,5 @@
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
export interface IApplicationFactory { export interface IApplicationFactory {
getApp(): Promise<IApplication>; getApp(): Promise<IApplication>;
} }

View File

@@ -1,41 +1,38 @@
import { CollectionData } from 'js-yaml-loader!@/*';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { IProjectInformation } from '@/domain/IProjectInformation'; import { IProjectInformation } from '@/domain/IProjectInformation';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { parseCategoryCollection } from './CategoryCollectionParser';
import WindowsData from 'js-yaml-loader!@/application/collections/windows.yaml'; import WindowsData from 'js-yaml-loader!@/application/collections/windows.yaml';
import MacOsData from 'js-yaml-loader!@/application/collections/macos.yaml'; import MacOsData from 'js-yaml-loader!@/application/collections/macos.yaml';
import { CollectionData } from 'js-yaml-loader!@/*';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser'; import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { Application } from '@/domain/Application'; import { Application } from '@/domain/Application';
import { parseCategoryCollection } from './CategoryCollectionParser';
export function parseApplication( export function parseApplication(
parser = CategoryCollectionParser, parser = CategoryCollectionParser,
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
collectionsData = PreParsedCollections, collectionsData = PreParsedCollections): IApplication {
): IApplication { validateCollectionsData(collectionsData);
validateCollectionsData(collectionsData); const information = parseProjectInformation(processEnv);
const information = parseProjectInformation(processEnv); const collections = collectionsData.map((collection) => parser(collection, information));
const collections = collectionsData.map((collection) => parser(collection, information)); const app = new Application(information, collections);
const app = new Application(information, collections); return app;
return app;
} }
export type CategoryCollectionParserType export type CategoryCollectionParserType
= (file: CollectionData, info: IProjectInformation) => ICategoryCollection; = (file: CollectionData, info: IProjectInformation) => ICategoryCollection;
const CategoryCollectionParser: CategoryCollectionParserType = (file, info) => { const CategoryCollectionParser: CategoryCollectionParserType
return parseCategoryCollection(file, info); = (file, info) => parseCategoryCollection(file, info);
};
const PreParsedCollections: readonly CollectionData [] = [ const PreParsedCollections: readonly CollectionData []
WindowsData, MacOsData, = [ WindowsData, MacOsData ];
];
function validateCollectionsData(collections: readonly CollectionData[]) { function validateCollectionsData(collections: readonly CollectionData[]) {
if (!collections.length) { if (!collections.length) {
throw new Error('no collection provided'); throw new Error('no collection provided');
} }
if (collections.some((collection) => !collection)) { if (collections.some((collection) => !collection)) {
throw new Error('undefined collection provided'); throw new Error('undefined collection provided');
} }
} }

View File

@@ -1,37 +1,40 @@
import { Category } from '@/domain/Category';
import { CollectionData } from 'js-yaml-loader!@/*'; import { CollectionData } from 'js-yaml-loader!@/*';
import { parseCategory } from './CategoryParser';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { createEnumParser } from '../Common/Enum';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategoryCollection } from '@/domain/CategoryCollection'; import { CategoryCollection } from '@/domain/CategoryCollection';
import { IProjectInformation } from '@/domain/IProjectInformation'; import { IProjectInformation } from '@/domain/IProjectInformation';
import { createEnumParser } from '../Common/Enum';
import { parseCategory } from './CategoryParser';
import { CategoryCollectionParseContext } from './Script/CategoryCollectionParseContext'; import { CategoryCollectionParseContext } from './Script/CategoryCollectionParseContext';
import { ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; import { ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
export function parseCategoryCollection( export function parseCategoryCollection(
content: CollectionData, content: CollectionData,
info: IProjectInformation, info: IProjectInformation,
osParser = createEnumParser(OperatingSystem), osParser = createEnumParser(OperatingSystem)): ICategoryCollection {
): ICategoryCollection { validate(content);
validate(content); const scripting = new ScriptingDefinitionParser()
const scripting = new ScriptingDefinitionParser() .parse(content.scripting, info);
.parse(content.scripting, info); const context = new CategoryCollectionParseContext(content.functions, scripting);
const context = new CategoryCollectionParseContext(content.functions, scripting); const categories = new Array<Category>();
const categories = content.actions.map((action) => parseCategory(action, context)); for (const action of content.actions) {
const os = osParser.parseEnum(content.os, 'os'); const category = parseCategory(action, context);
const collection = new CategoryCollection( categories.push(category);
os, }
categories, const os = osParser.parseEnum(content.os, 'os');
scripting, const collection = new CategoryCollection(
); os,
return collection; categories,
scripting);
return collection;
} }
function validate(content: CollectionData): void { function validate(content: CollectionData): void {
if (!content) { if (!content) {
throw new Error('content is null or undefined'); throw new Error('content is null or undefined');
} }
if (!content.actions || content.actions.length <= 0) { if (!content.actions || content.actions.length <= 0) {
throw new Error('content does not define any action'); throw new Error('content does not define any action');
} }
} }

View File

@@ -1,86 +1,71 @@
import { import { CategoryData, ScriptData, CategoryOrScriptData } from 'js-yaml-loader!@/*';
CategoryData, ScriptData, CategoryOrScriptData, InstructionHolder,
} from 'js-yaml-loader!@/*';
import { Script } from '@/domain/Script'; import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category'; import { Category } from '@/domain/Category';
import { parseDocUrls } from './DocumentationParser'; import { parseDocUrls } from './DocumentationParser';
import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext'; import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
import { parseScript } from './Script/ScriptParser'; import { parseScript } from './Script/ScriptParser';
let categoryIdCounter = 0; let categoryIdCounter: number = 0;
export function parseCategory( interface ICategoryChildren {
category: CategoryData, subCategories: Category[];
context: ICategoryCollectionParseContext, subScripts: Script[];
): Category { }
if (!context) { throw new Error('undefined context'); }
ensureValid(category); export function parseCategory(category: CategoryData, context: ICategoryCollectionParseContext): Category {
const children: ICategoryChildren = { if (!context) { throw new Error('undefined context'); }
subCategories: new Array<Category>(), ensureValid(category);
subScripts: new Array<Script>(), const children: ICategoryChildren = {
}; subCategories: new Array<Category>(),
for (const data of category.children) { subScripts: new Array<Script>(),
parseCategoryChild(data, children, category, context); };
} for (const data of category.children) {
return new Category( parseCategoryChild(data, children, category, context);
/* id: */ categoryIdCounter++, }
/* name: */ category.category, return new Category(
/* docs: */ parseDocUrls(category), /*id*/ categoryIdCounter++,
/* categories: */ children.subCategories, /*name*/ category.category,
/* scripts: */ children.subScripts, /*docs*/ parseDocUrls(category),
); /*categories*/ children.subCategories,
/*scripts*/ children.subScripts,
);
} }
function ensureValid(category: CategoryData) { function ensureValid(category: CategoryData) {
if (!category) { if (!category) {
throw Error('category is null or undefined'); throw Error('category is null or undefined');
} }
if (!category.children || category.children.length === 0) { if (!category.children || category.children.length === 0) {
throw Error(`category has no children: "${category.category}"`); throw Error(`category has no children: "${category.category}"`);
} }
if (!category.category || category.category.length === 0) { if (!category.category || category.category.length === 0) {
throw Error('category has no name'); throw Error('category has no name');
} }
}
interface ICategoryChildren {
subCategories: Category[];
subScripts: Script[];
} }
function parseCategoryChild( function parseCategoryChild(
data: CategoryOrScriptData, data: CategoryOrScriptData,
children: ICategoryChildren, children: ICategoryChildren,
parent: CategoryData, parent: CategoryData,
context: ICategoryCollectionParseContext, context: ICategoryCollectionParseContext) {
) { if (isCategory(data)) {
if (isCategory(data)) { const subCategory = parseCategory(data as CategoryData, context);
const subCategory = parseCategory(data as CategoryData, context); children.subCategories.push(subCategory);
children.subCategories.push(subCategory); } else if (isScript(data)) {
} else if (isScript(data)) { const scriptData = data as ScriptData;
const scriptData = data as ScriptData; const script = parseScript(scriptData, context);
const script = parseScript(scriptData, context); children.subScripts.push(script);
children.subScripts.push(script); } else {
} else { throw new Error(`Child element is neither a category or a script.
throw new Error(`Child element is neither a category or a script.
Parent: ${parent.category}, element: ${JSON.stringify(data)}`); Parent: ${parent.category}, element: ${JSON.stringify(data)}`);
} }
} }
function isScript(data: CategoryOrScriptData): data is ScriptData { function isScript(data: any): boolean {
const holder = (data as InstructionHolder); return (data.code && data.code.length > 0)
return hasCode(holder) || hasCall(holder); || data.call;
} }
function isCategory(data: CategoryOrScriptData): data is CategoryData { function isCategory(data: any): boolean {
const { category } = data as CategoryData; return data.category && data.category.length > 0;
return category && category.length > 0;
}
function hasCode(holder: InstructionHolder): boolean {
return holder.code && holder.code.length > 0;
}
function hasCall(holder: InstructionHolder) {
return holder.call !== undefined;
} }

View File

@@ -1,64 +1,61 @@
import { DocumentableData, DocumentationUrlsData } from 'js-yaml-loader!@/*'; import { DocumentableData, DocumentationUrlsData } from 'js-yaml-loader!@/*';
export function parseDocUrls(documentable: DocumentableData): ReadonlyArray<string> { export function parseDocUrls(documentable: DocumentableData): ReadonlyArray<string> {
if (!documentable) { if (!documentable) {
throw new Error('documentable is null or undefined'); throw new Error('documentable is null or undefined');
} }
const { docs } = documentable; const docs = documentable.docs;
if (!docs || !docs.length) { if (!docs || !docs.length) {
return []; return [];
} }
let result = new DocumentationUrlContainer(); let result = new DocumentationUrlContainer();
result = addDocs(docs, result); result = addDocs(docs, result);
return result.getAll(); return result.getAll();
} }
function addDocs( function addDocs(docs: DocumentationUrlsData, urls: DocumentationUrlContainer): DocumentationUrlContainer {
docs: DocumentationUrlsData, if (docs instanceof Array) {
urls: DocumentationUrlContainer, urls.addUrls(docs);
): DocumentationUrlContainer { } else if (typeof docs === 'string') {
if (docs instanceof Array) { urls.addUrl(docs);
urls.addUrls(docs); } else {
} else if (typeof docs === 'string') { throw new Error('Docs field (documentation url) must a string or array of strings');
urls.addUrl(docs); }
} else { return urls;
throw new Error('Docs field (documentation url) must a string or array of strings');
}
return urls;
} }
class DocumentationUrlContainer { class DocumentationUrlContainer {
private readonly urls = new Array<string>(); private readonly urls = new Array<string>();
public addUrl(url: string) { public addUrl(url: string) {
validateUrl(url); validateUrl(url);
this.urls.push(url); this.urls.push(url);
}
public addUrls(urls: readonly string[]) {
for (const url of urls) {
if (typeof url !== 'string') {
throw new Error('Docs field (documentation url) must be an array of strings');
}
this.addUrl(url);
} }
}
public getAll(): ReadonlyArray<string> { public addUrls(urls: readonly any[]) {
return this.urls; for (const url of urls) {
} if (typeof url !== 'string') {
throw new Error('Docs field (documentation url) must be an array of strings');
}
this.addUrl(url);
}
}
public getAll(): ReadonlyArray<string> {
return this.urls;
}
} }
function validateUrl(docUrl: string): void { function validateUrl(docUrl: string): void {
if (!docUrl) { if (!docUrl) {
throw new Error('Documentation url is null or empty'); throw new Error('Documentation url is null or empty');
} }
if (docUrl.includes('\n')) { if (docUrl.includes('\n')) {
throw new Error('Documentation url cannot be multi-lined.'); throw new Error('Documentation url cannot be multi-lined.');
} }
const validUrlRegex = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g; const res = docUrl.match(
const res = docUrl.match(validUrlRegex); /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g);
if (res == null) { if (res == null) {
throw new Error(`Invalid documentation url: ${docUrl}`); throw new Error(`Invalid documentation url: ${docUrl}`);
} }
} }

View File

@@ -2,12 +2,11 @@ import { IProjectInformation } from '@/domain/IProjectInformation';
import { ProjectInformation } from '@/domain/ProjectInformation'; import { ProjectInformation } from '@/domain/ProjectInformation';
export function parseProjectInformation( export function parseProjectInformation(
environment: NodeJS.ProcessEnv, environment: NodeJS.ProcessEnv): IProjectInformation {
): IProjectInformation { return new ProjectInformation(
return new ProjectInformation( environment.VUE_APP_NAME,
environment.VUE_APP_NAME, environment.VUE_APP_VERSION,
environment.VUE_APP_VERSION, environment.VUE_APP_REPOSITORY_URL,
environment.VUE_APP_REPOSITORY_URL, environment.VUE_APP_HOMEPAGE_URL,
environment.VUE_APP_HOMEPAGE_URL, );
);
} }

View File

@@ -1,6 +1,6 @@
import { FunctionData } from 'js-yaml-loader!@/*';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ILanguageSyntax } from '@/domain/ScriptCode'; import { ILanguageSyntax } from '@/domain/ScriptCode';
import { FunctionData } from 'js-yaml-loader!@/*';
import { IScriptCompiler } from './Compiler/IScriptCompiler'; import { IScriptCompiler } from './Compiler/IScriptCompiler';
import { ScriptCompiler } from './Compiler/ScriptCompiler'; import { ScriptCompiler } from './Compiler/ScriptCompiler';
import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext'; import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
@@ -8,17 +8,15 @@ import { SyntaxFactory } from './Syntax/SyntaxFactory';
import { ISyntaxFactory } from './Syntax/ISyntaxFactory'; import { ISyntaxFactory } from './Syntax/ISyntaxFactory';
export class CategoryCollectionParseContext implements ICategoryCollectionParseContext { export class CategoryCollectionParseContext implements ICategoryCollectionParseContext {
public readonly compiler: IScriptCompiler; public readonly compiler: IScriptCompiler;
public readonly syntax: ILanguageSyntax;
public readonly syntax: ILanguageSyntax; constructor(
functionsData: ReadonlyArray<FunctionData> | undefined,
constructor( scripting: IScriptingDefinition,
functionsData: ReadonlyArray<FunctionData> | undefined, syntaxFactory: ISyntaxFactory = new SyntaxFactory()) {
scripting: IScriptingDefinition, if (!scripting) { throw new Error('undefined scripting'); }
syntaxFactory: ISyntaxFactory = new SyntaxFactory(), this.syntax = syntaxFactory.create(scripting.language);
) { this.compiler = new ScriptCompiler(functionsData, this.syntax);
if (!scripting) { throw new Error('undefined scripting'); } }
this.syntax = syntaxFactory.create(scripting.language);
this.compiler = new ScriptCompiler(functionsData, this.syntax);
}
} }

View File

@@ -1,63 +1,62 @@
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection'; import { ExpressionPosition } from './ExpressionPosition';
import { IExpression } from './IExpression';
import { IReadOnlyFunctionCallArgumentCollection } from '../../Function/Call/Argument/IFunctionCallArgumentCollection'; import { IReadOnlyFunctionCallArgumentCollection } from '../../Function/Call/Argument/IFunctionCallArgumentCollection';
import { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection'; import { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection';
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { FunctionCallArgumentCollection } from '../../Function/Call/Argument/FunctionCallArgumentCollection'; import { FunctionCallArgumentCollection } from '../../Function/Call/Argument/FunctionCallArgumentCollection';
import { IExpression } from './IExpression'; import { IExpressionEvaluationContext } from './ExpressionEvaluationContext';
import { ExpressionPosition } from './ExpressionPosition'; import { ExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
import { ExpressionEvaluationContext, IExpressionEvaluationContext } from './ExpressionEvaluationContext';
export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string; export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string;
export class Expression implements IExpression { export class Expression implements IExpression {
constructor( constructor(
public readonly position: ExpressionPosition, public readonly position: ExpressionPosition,
public readonly evaluator: ExpressionEvaluator, public readonly evaluator: ExpressionEvaluator,
public readonly parameters public readonly parameters: IReadOnlyFunctionParameterCollection = new FunctionParameterCollection()) {
: IReadOnlyFunctionParameterCollection = new FunctionParameterCollection(), if (!position) {
) { throw new Error('undefined position');
if (!position) { }
throw new Error('undefined position'); if (!evaluator) {
throw new Error('undefined evaluator');
}
} }
if (!evaluator) { public evaluate(context: IExpressionEvaluationContext): string {
throw new Error('undefined evaluator'); if (!context) {
throw new Error('undefined context');
}
validateThatAllRequiredParametersAreSatisfied(this.parameters, context.args);
const args = filterUnusedArguments(this.parameters, context.args);
context = new ExpressionEvaluationContext(args, context.pipelineCompiler);
return this.evaluator(context);
} }
}
public evaluate(context: IExpressionEvaluationContext): string {
if (!context) {
throw new Error('undefined context');
}
validateThatAllRequiredParametersAreSatisfied(this.parameters, context.args);
const args = filterUnusedArguments(this.parameters, context.args);
const filteredContext = new ExpressionEvaluationContext(args, context.pipelineCompiler);
return this.evaluator(filteredContext);
}
} }
function validateThatAllRequiredParametersAreSatisfied( function validateThatAllRequiredParametersAreSatisfied(
parameters: IReadOnlyFunctionParameterCollection, parameters: IReadOnlyFunctionParameterCollection,
args: IReadOnlyFunctionCallArgumentCollection, args: IReadOnlyFunctionCallArgumentCollection,
) { ) {
const requiredParameterNames = parameters const requiredParameterNames = parameters
.all .all
.filter((parameter) => !parameter.isOptional) .filter((parameter) => !parameter.isOptional)
.map((parameter) => parameter.name); .map((parameter) => parameter.name);
const missingParameterNames = requiredParameterNames const missingParameterNames = requiredParameterNames
.filter((parameterName) => !args.hasArgument(parameterName)); .filter((parameterName) => !args.hasArgument(parameterName));
if (missingParameterNames.length) { if (missingParameterNames.length) {
throw new Error( throw new Error(
`argument values are provided for required parameters: "${missingParameterNames.join('", "')}"`, `argument values are provided for required parameters: "${missingParameterNames.join('", "')}"`);
); }
}
} }
function filterUnusedArguments( function filterUnusedArguments(
parameters: IReadOnlyFunctionParameterCollection, parameters: IReadOnlyFunctionParameterCollection,
allFunctionArgs: IReadOnlyFunctionCallArgumentCollection, allFunctionArgs: IReadOnlyFunctionCallArgumentCollection): IReadOnlyFunctionCallArgumentCollection {
): IReadOnlyFunctionCallArgumentCollection { const specificCallArgs = new FunctionCallArgumentCollection();
const specificCallArgs = new FunctionCallArgumentCollection(); for (const parameter of parameters.all) {
parameters.all if (parameter.isOptional && !allFunctionArgs.hasArgument(parameter.name)) {
.filter((parameter) => allFunctionArgs.hasArgument(parameter.name)) continue; // Optional parameter is not necessarily provided
.map((parameter) => allFunctionArgs.getArgument(parameter.name)) }
.forEach((argument) => specificCallArgs.addArgument(argument)); const arg = allFunctionArgs.getArgument(parameter.name);
return specificCallArgs; specificCallArgs.addArgument(arg);
}
return specificCallArgs;
} }

View File

@@ -3,17 +3,16 @@ import { IPipelineCompiler } from '../Pipes/IPipelineCompiler';
import { PipelineCompiler } from '../Pipes/PipelineCompiler'; import { PipelineCompiler } from '../Pipes/PipelineCompiler';
export interface IExpressionEvaluationContext { export interface IExpressionEvaluationContext {
readonly args: IReadOnlyFunctionCallArgumentCollection; readonly args: IReadOnlyFunctionCallArgumentCollection;
readonly pipelineCompiler: IPipelineCompiler; readonly pipelineCompiler: IPipelineCompiler;
} }
export class ExpressionEvaluationContext implements IExpressionEvaluationContext { export class ExpressionEvaluationContext implements IExpressionEvaluationContext {
constructor( constructor(
public readonly args: IReadOnlyFunctionCallArgumentCollection, public readonly args: IReadOnlyFunctionCallArgumentCollection,
public readonly pipelineCompiler: IPipelineCompiler = new PipelineCompiler(), public readonly pipelineCompiler: IPipelineCompiler = new PipelineCompiler()) {
) { if (!args) {
if (!args) { throw new Error('undefined args, send empty collection instead');
throw new Error('undefined args, send empty collection instead'); }
} }
}
} }

View File

@@ -1,16 +1,15 @@
export class ExpressionPosition { export class ExpressionPosition {
constructor( constructor(
public readonly start: number, public readonly start: number,
public readonly end: number, public readonly end: number) {
) { if (start === end) {
if (start === end) { throw new Error(`no length (start = end = ${start})`);
throw new Error(`no length (start = end = ${start})`); }
if (start > end) {
throw Error(`start (${start}) after end (${end})`);
}
if (start < 0) {
throw Error(`negative start position: ${start}`);
}
} }
if (start > end) {
throw Error(`start (${start}) after end (${end})`);
}
if (start < 0) {
throw Error(`negative start position: ${start}`);
}
}
} }

View File

@@ -1,9 +1,9 @@
import { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection';
import { ExpressionPosition } from './ExpressionPosition'; import { ExpressionPosition } from './ExpressionPosition';
import { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection';
import { IExpressionEvaluationContext } from './ExpressionEvaluationContext'; import { IExpressionEvaluationContext } from './ExpressionEvaluationContext';
export interface IExpression { export interface IExpression {
readonly position: ExpressionPosition; readonly position: ExpressionPosition;
readonly parameters: IReadOnlyFunctionParameterCollection; readonly parameters: IReadOnlyFunctionParameterCollection;
evaluate(context: IExpressionEvaluationContext): string; evaluate(context: IExpressionEvaluationContext): string;
} }

View File

@@ -1,85 +1,78 @@
import { IExpressionEvaluationContext, ExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
import { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection';
import { IExpressionsCompiler } from './IExpressionsCompiler'; import { IExpressionsCompiler } from './IExpressionsCompiler';
import { IExpression } from './Expression/IExpression'; import { IExpression } from './Expression/IExpression';
import { IExpressionParser } from './Parser/IExpressionParser'; import { IExpressionParser } from './Parser/IExpressionParser';
import { CompositeExpressionParser } from './Parser/CompositeExpressionParser'; import { CompositeExpressionParser } from './Parser/CompositeExpressionParser';
import { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection';
import { ExpressionEvaluationContext } from './Expression/ExpressionEvaluationContext';
import { IExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
export class ExpressionsCompiler implements IExpressionsCompiler { export class ExpressionsCompiler implements IExpressionsCompiler {
public constructor( public constructor(
private readonly extractor: IExpressionParser = new CompositeExpressionParser(), private readonly extractor: IExpressionParser = new CompositeExpressionParser()) { }
) { } public compileExpressions(
code: string,
public compileExpressions( args: IReadOnlyFunctionCallArgumentCollection): string {
code: string | undefined, if (!args) {
args: IReadOnlyFunctionCallArgumentCollection, throw new Error('undefined args, send empty collection instead');
): string { }
if (!args) { const expressions = this.extractor.findExpressions(code);
throw new Error('undefined args, send empty collection instead'); ensureParamsUsedInCodeHasArgsProvided(expressions, args);
const context = new ExpressionEvaluationContext(args);
const compiledCode = compileExpressions(expressions, code, context);
return compiledCode;
} }
if (!code) {
return code;
}
const expressions = this.extractor.findExpressions(code);
ensureParamsUsedInCodeHasArgsProvided(expressions, args);
const context = new ExpressionEvaluationContext(args);
const compiledCode = compileExpressions(expressions, code, context);
return compiledCode;
}
} }
function compileExpressions( function compileExpressions(
expressions: readonly IExpression[], expressions: readonly IExpression[],
code: string, code: string,
context: IExpressionEvaluationContext, context: IExpressionEvaluationContext) {
) { let compiledCode = '';
let compiledCode = ''; const sortedExpressions = expressions
const sortedExpressions = expressions .slice() // copy the array to not mutate the parameter
.slice() // copy the array to not mutate the parameter .sort((a, b) => b.position.start - a.position.start);
.sort((a, b) => b.position.start - a.position.start); let index = 0;
let index = 0; while (index !== code.length) {
while (index !== code.length) { const nextExpression = sortedExpressions.pop();
const nextExpression = sortedExpressions.pop(); if (nextExpression) {
if (nextExpression) { compiledCode += code.substring(index, nextExpression.position.start);
compiledCode += code.substring(index, nextExpression.position.start); const expressionCode = nextExpression.evaluate(context);
const expressionCode = nextExpression.evaluate(context); compiledCode += expressionCode;
compiledCode += expressionCode; index = nextExpression.position.end;
index = nextExpression.position.end; } else {
} else { compiledCode += code.substring(index, code.length);
compiledCode += code.substring(index, code.length); break;
break; }
} }
} return compiledCode;
return compiledCode;
} }
function extractRequiredParameterNames( function extractRequiredParameterNames(
expressions: readonly IExpression[], expressions: readonly IExpression[]): string[] {
): string[] { const usedParameterNames = expressions
return expressions .map((e) => e.parameters.all
.map((e) => e.parameters.all .filter((p) => !p.isOptional)
.filter((p) => !p.isOptional) .map((p) => p.name))
.map((p) => p.name)) .filter((p) => p)
.filter(Boolean) // Remove empty or undefined .flat();
.flat() const uniqueParameterNames = Array.from(new Set(usedParameterNames));
.filter((name, index, array) => array.indexOf(name) === index); // Remove duplicates return uniqueParameterNames;
} }
function ensureParamsUsedInCodeHasArgsProvided( function ensureParamsUsedInCodeHasArgsProvided(
expressions: readonly IExpression[], expressions: readonly IExpression[],
providedArgs: IReadOnlyFunctionCallArgumentCollection, providedArgs: IReadOnlyFunctionCallArgumentCollection): void {
): void { const usedParameterNames = extractRequiredParameterNames(expressions);
const usedParameterNames = extractRequiredParameterNames(expressions); if (!usedParameterNames?.length) {
if (!usedParameterNames?.length) { return;
return; }
} const notProvidedParameters = usedParameterNames
const notProvidedParameters = usedParameterNames .filter((parameterName) => !providedArgs.hasArgument(parameterName));
.filter((parameterName) => !providedArgs.hasArgument(parameterName)); if (notProvidedParameters.length) {
if (notProvidedParameters.length) { throw new Error(`parameter value(s) not provided for: ${printList(notProvidedParameters)} but used in code`);
throw new Error(`parameter value(s) not provided for: ${printList(notProvidedParameters)} but used in code`); }
}
} }
function printList(list: readonly string[]): string { function printList(list: readonly string[]): string {
return `"${list.join('", "')}"`; return `"${list.join('", "')}"`;
} }

View File

@@ -1,7 +1,7 @@
import { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection'; import { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection';
export interface IExpressionsCompiler { export interface IExpressionsCompiler {
compileExpressions( compileExpressions(
code: string | undefined, code: string,
args: IReadOnlyFunctionCallArgumentCollection): string; args: IReadOnlyFunctionCallArgumentCollection): string;
} }

View File

@@ -1,23 +1,27 @@
import { IExpression } from '../Expression/IExpression'; import { IExpression } from '../Expression/IExpression';
import { IExpressionParser } from './IExpressionParser';
import { ParameterSubstitutionParser } from '../SyntaxParsers/ParameterSubstitutionParser'; import { ParameterSubstitutionParser } from '../SyntaxParsers/ParameterSubstitutionParser';
import { WithParser } from '../SyntaxParsers/WithParser'; import { WithParser } from '../SyntaxParsers/WithParser';
import { IExpressionParser } from './IExpressionParser';
const Parsers = [ const Parsers = [
new ParameterSubstitutionParser(), new ParameterSubstitutionParser(),
new WithParser(), new WithParser(),
]; ];
export class CompositeExpressionParser implements IExpressionParser { export class CompositeExpressionParser implements IExpressionParser {
public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) { public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {
if (leafs.some((leaf) => !leaf)) { if (leafs.some((leaf) => !leaf)) {
throw new Error('undefined leaf'); throw new Error('undefined leaf');
}
}
public findExpressions(code: string): IExpression[] {
const expressions = new Array<IExpression>();
for (const parser of this.leafs) {
const newExpressions = parser.findExpressions(code);
if (newExpressions && newExpressions.length) {
expressions.push(...newExpressions);
}
}
return expressions;
} }
}
public findExpressions(code: string): IExpression[] {
return this.leafs.flatMap(
(parser) => parser.findExpressions(code) || [],
);
}
} }

View File

@@ -1,5 +1,5 @@
import { IExpression } from '../Expression/IExpression'; import { IExpression } from '../Expression/IExpression';
export interface IExpressionParser { export interface IExpressionParser {
findExpressions(code: string): IExpression[]; findExpressions(code: string): IExpression[];
} }

View File

@@ -1,60 +1,59 @@
export class ExpressionRegexBuilder { export class ExpressionRegexBuilder {
private readonly parts = new Array<string>(); private readonly parts = new Array<string>();
public expectCharacters(characters: string) { public expectCharacters(characters: string) {
return this.addRawRegex( return this.addRawRegex(
characters characters
.replaceAll('$', '\\$') .replaceAll('$', '\\$')
.replaceAll('.', '\\.'), .replaceAll('.', '\\.'),
); );
} }
public expectOneOrMoreWhitespaces() { public expectOneOrMoreWhitespaces() {
return this return this
.addRawRegex('\\s+'); .addRawRegex('\\s+');
} }
public matchPipeline() { public matchPipeline() {
return this return this
.expectZeroOrMoreWhitespaces() .expectZeroOrMoreWhitespaces()
.addRawRegex('(\\|\\s*.+?)?'); .addRawRegex('(\\|\\s*.+?)?');
} }
public matchUntilFirstWhitespace() { public matchUntilFirstWhitespace() {
return this return this
.addRawRegex('([^|\\s]+)'); .addRawRegex('([^|\\s]+)');
} }
public matchAnythingExceptSurroundingWhitespaces() { public matchAnythingExceptSurroundingWhitespaces() {
return this return this
.expectZeroOrMoreWhitespaces() .expectZeroOrMoreWhitespaces()
.addRawRegex('(.+?)') .addRawRegex('(.+?)')
.expectZeroOrMoreWhitespaces(); .expectZeroOrMoreWhitespaces();
} }
public expectExpressionStart() { public expectExpressionStart() {
return this return this
.expectCharacters('{{') .expectCharacters('{{')
.expectZeroOrMoreWhitespaces(); .expectZeroOrMoreWhitespaces();
} }
public expectExpressionEnd() { public expectExpressionEnd() {
return this return this
.expectZeroOrMoreWhitespaces() .expectZeroOrMoreWhitespaces()
.expectCharacters('}}'); .expectCharacters('}}');
} }
public buildRegExp(): RegExp { public buildRegExp(): RegExp {
return new RegExp(this.parts.join(''), 'g'); return new RegExp(this.parts.join(''), 'g');
} }
private expectZeroOrMoreWhitespaces() { private expectZeroOrMoreWhitespaces() {
return this return this
.addRawRegex('\\s*'); .addRawRegex('\\s*');
} }
private addRawRegex(regex: string) {
private addRawRegex(regex: string) { this.parts.push(regex);
this.parts.push(regex); return this;
return this; }
}
} }

View File

@@ -6,54 +6,43 @@ import { IFunctionParameter } from '../../../Function/Parameter/IFunctionParamet
import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection'; import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection';
export abstract class RegexParser implements IExpressionParser { export abstract class RegexParser implements IExpressionParser {
protected abstract readonly regex: RegExp; protected abstract readonly regex: RegExp;
public findExpressions(code: string): IExpression[] { public findExpressions(code: string): IExpression[] {
return Array.from(this.findRegexExpressions(code)); return Array.from(this.findRegexExpressions(code));
}
protected abstract buildExpression(match: RegExpMatchArray): IPrimitiveExpression;
private* findRegexExpressions(code: string): Iterable<IExpression> {
if (!code) {
throw new Error('undefined code');
} }
const matches = code.matchAll(this.regex);
for (const match of matches) { protected abstract buildExpression(match: RegExpMatchArray): IPrimitiveExpression;
const primitiveExpression = this.buildExpression(match);
const position = this.doOrRethrow(() => createPosition(match), 'invalid script position', code); private* findRegexExpressions(code: string): Iterable<IExpression> {
const parameters = createParameters(primitiveExpression); const matches = Array.from(code.matchAll(this.regex));
const expression = new Expression(position, primitiveExpression.evaluator, parameters); for (const match of matches) {
yield expression; const startPos = match.index;
const endPos = startPos + match[0].length;
let position: ExpressionPosition;
try {
position = new ExpressionPosition(startPos, endPos);
} catch (error) {
throw new Error(`[${this.constructor.name}] invalid script position: ${error.message}\nRegex ${this.regex}\nCode: ${code}`);
}
const primitiveExpression = this.buildExpression(match);
const parameters = getParameters(primitiveExpression);
const expression = new Expression(position, primitiveExpression.evaluator, parameters);
yield expression;
}
} }
}
private doOrRethrow<T>(action: () => T, errorText: string, code: string): T {
try {
return action();
} catch (error) {
throw new Error(`[${this.constructor.name}] ${errorText}: ${error.message}\nRegex: ${this.regex}\nCode: ${code}`);
}
}
}
function createPosition(match: RegExpMatchArray): ExpressionPosition {
const startPos = match.index;
const endPos = startPos + match[0].length;
return new ExpressionPosition(startPos, endPos);
}
function createParameters(
expression: IPrimitiveExpression,
): FunctionParameterCollection {
return (expression.parameters || [])
.reduce((parameters, parameter) => {
parameters.addParameter(parameter);
return parameters;
}, new FunctionParameterCollection());
} }
export interface IPrimitiveExpression { export interface IPrimitiveExpression {
evaluator: ExpressionEvaluator; evaluator: ExpressionEvaluator;
parameters?: readonly IFunctionParameter[]; parameters?: readonly IFunctionParameter[];
}
function getParameters(
expression: IPrimitiveExpression): FunctionParameterCollection {
const parameters = new FunctionParameterCollection();
for (const parameter of expression.parameters || []) {
parameters.addParameter(parameter);
}
return parameters;
} }

View File

@@ -1,4 +1,4 @@
export interface IPipe { export interface IPipe {
readonly name: string; readonly name: string;
apply(input: string): string; apply(input: string): string;
} }

View File

@@ -1,3 +1,3 @@
export interface IPipelineCompiler { export interface IPipelineCompiler {
compile(value: string, pipeline: string): string; compile(value: string, pipeline: string): string;
} }

View File

@@ -1,30 +1,27 @@
import { IPipe } from '../IPipe'; import { IPipe } from '../IPipe';
export class EscapeDoubleQuotes implements IPipe { export class EscapeDoubleQuotes implements IPipe {
public readonly name: string = 'escapeDoubleQuotes'; public readonly name: string = 'escapeDoubleQuotes';
public apply(raw: string): string {
public apply(raw: string): string { return raw?.replaceAll('"', '"^""');
return raw?.replaceAll('"', '"^""'); /*
/* eslint-disable max-len */ "^"" is the most robust and stable choice.
/* Other options:
"^"" is the most robust and stable choice. ""
Other options: Breaks, because it is fundamentally unsupported
"" """"
Breaks, because it is fundamentally unsupported Does not work with consecutive double quotes.
"""" E.g. PowerShell -Command "$name='aq'; Write-Host """"Disabled `""""$name`"""""""";"
Does not work with consecutive double quotes. Works when using: PowerShell -Command "$name='aq'; Write-Host "^""Disabled `"^""$name`"^"" "^"";"
E.g. `PowerShell -Command "$name='aq'; Write-Host """"Disabled `""""$name`"""""""";"` \"
Works when using: `PowerShell -Command "$name='aq'; Write-Host "^""Disabled `"^""$name`"^"" "^"";"` May break as they are interpreted by cmd.exe as metacharacters breaking the command
\" E.g. PowerShell -Command "Write-Host 'Hello \"w&orld\"'" does not work due to unescaped "&"
May break as they are interpreted by cmd.exe as metacharacters breaking the command Works when using: PowerShell -Command "Write-Host 'Hello "^""w&orld"^""'"
E.g. `PowerShell -Command "Write-Host 'Hello \"w&orld\"'"` does not work due to unescaped "&" \""
Works when using: `PowerShell -Command "Write-Host 'Hello "^""w&orld"^""'"` Normalizes interior whitespace
\"" E.g. PowerShell -Command "\""a& c\"".length", outputs 4 and discards one of two whitespaces
Normalizes interior whitespace Works when using "^"": PowerShell -Command ""^""a& c"^"".length"
E.g. `PowerShell -Command "\""a& c\"".length"`, outputs 4 and discards one of two whitespaces A good explanation: https://stackoverflow.com/a/31413730
Works when using "^"": `PowerShell -Command ""^""a& c"^"".length"` */
A good explanation: https://stackoverflow.com/a/31413730 }
*/
/* eslint-enable max-len */
}
} }

View File

@@ -1,166 +1,155 @@
import { IPipe } from '../IPipe'; import { IPipe } from '../IPipe';
export class InlinePowerShell implements IPipe { export class InlinePowerShell implements IPipe {
public readonly name: string = 'inlinePowerShell'; public readonly name: string = 'inlinePowerShell';
public apply(code: string): string {
public apply(code: string): string { if (!code || !hasLines(code)) {
if (!code || !hasLines(code)) { return code;
return code; }
code = inlineComments(code);
code = mergeLinesWithBacktick(code);
code = mergeHereStrings(code);
const lines = getLines(code)
.map((line) => line.trim())
.filter((line) => line.length > 0);
return lines
.join('; ');
} }
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
inlineComments,
mergeLinesWithBacktick,
mergeHereStrings,
mergeNewLines,
]).reduce((a, b) => (data) => b(a(data)));
const newCode = processor(code);
return newCode;
}
} }
function hasLines(text: string) { function hasLines(text: string) {
return text.includes('\n') || text.includes('\r'); return text.includes('\n') || text.includes('\r');
} }
/* /*
Line comments using "#" are replaced with inline comment syntax <# comment.. #> Line comments using "#" are replaced with inline comment syntax <# comment.. #>
Otherwise single # comments out rest of the code Otherwise single # comments out rest of the code
*/ */
function inlineComments(code: string): string { function inlineComments(code: string): string {
const makeInlineComment = (comment: string) => { const makeInlineComment = (comment: string) => {
const value = comment?.trim(); const value = comment?.trim();
if (!value) { if (!value) {
return '<##>'; return '<##>';
} }
return `<# ${value} #>`; return `<# ${value} #>`;
}; };
return code.replaceAll(/<#.*?#>|#(.*)/g, (match, captureComment) => { return code.replaceAll(/<#.*?#>|#(.*)/g, (match, captureComment) => {
if (captureComment === undefined) { if (captureComment === undefined) {
return match;
}
return makeInlineComment(captureComment);
});
/*
Other alternatives considered:
--------------------------
/#(?<!<#)(?![<>])(.*)$/gm
-------------------------
✅ Simple, yet matches and captures only what's necessary
❌ Fails to match some cases
❌ `Write-Host "hi" # Comment ending line inline comment but not one #>`
❌ `Write-Host "hi" <#Comment starting like inline comment start but not one`
❌ `Write-Host "hi" #>Comment starting like inline comment end but not one`
❌ Uses lookbehind
Safari does not yet support lookbehind and syntax, leading application to not
load and throw "Invalid regular expression: invalid group specifier name"
https://caniuse.com/js-regexp-lookbehind
⏩ Usage
return code.replaceAll(/#(?<!<#)(?![<>])(.*)$/gm, (match, captureComment) => {
return makeInlineComment(captureComment)
});
----------------
/<#.*?#>|#(.*)/g
----------------
✅ Simple yet affective
❌ Matches all comments, but only captures dash comments
❌ Fails to match some cases
❌ `Write-Host "hi" # Comment ending line inline comment but not one #>`
❌ `Write-Host "hi" <#Comment starting like inline comment start but not one`
⏩ Usage
return code.replaceAll(/<#.*?#>|#(.*)/g, (match, captureComment) => {
if (captureComment === undefined) {
return match; return match;
} }
return makeInlineComment(captureComment); return makeInlineComment(captureComment);
}); });
------------------------------------ /*
/(^(?:<#.*?#>|[^#])*)(?:(#)(.*))?/gm Other alternatives considered:
------------------------------------ --------------------------
✅ Covers all cases /#(?<!<#)(?![<>])(.*)$/gm
❌ Matches every line, three capture groups are used to build result -------------------------
⏩ Usage ✅ Simple, yet matches and captures only what's necessary
return code.replaceAll(/(^(?:<#.*?#>|[^#])*)(?:(#)(.*))?/gm, ❌ Fails to match some cases
(match, captureLeft, captureDash, captureComment) => { ❌ `Write-Host "hi" # Comment ending line inline comment but not one #>`
if (!captureDash) { ❌ `Write-Host "hi" <#Comment starting like inline comment start but not one`
return match; ❌ `Write-Host "hi" #>Comment starting like inline comment end but not one`
} ❌ Uses lookbehind
return captureLeft + makeInlineComment(captureComment); Safari does not yet support lookbehind and syntax, leading application to not
}); load and throw "Invalid regular expression: invalid group specifier name"
https://caniuse.com/js-regexp-lookbehind
⏩ Usage
return code.replaceAll(/#(?<!<#)(?![<>])(.*)$/gm, (match, captureComment) => {
return makeInlineComment(captureComment)
});
----------------
/<#.*?#>|#(.*)/g
----------------
✅ Simple yet affective
❌ Matches all comments, but only captures dash comments
❌ Fails to match some cases
❌ `Write-Host "hi" # Comment ending line inline comment but not one #>`
❌ `Write-Host "hi" <#Comment starting like inline comment start but not one`
⏩ Usage
return code.replaceAll(/<#.*?#>|#(.*)/g, (match, captureComment) => {
if (captureComment === undefined) {
return match;
}
return makeInlineComment(captureComment);
});
------------------------------------
/(^(?:<#.*?#>|[^#])*)(?:(#)(.*))?/gm
------------------------------------
✅ Covers all cases
❌ Matches every line, three capture groups are used to build result
⏩ Usage
return code.replaceAll(/(^(?:<#.*?#>|[^#])*)(?:(#)(.*))?/gm,
(match, captureLeft, captureDash, captureComment) => {
if (!captureDash) {
return match;
}
return captureLeft + makeInlineComment(captureComment);
});
*/ */
} }
function getLines(code: string): string[] { function getLines(code: string): string [] {
return (code?.split(/\r\n|\r|\n/) || []); return (code?.split(/\r\n|\r|\n/) || []);
} }
/* /*
Merges inline here-strings to a single lined string with Windows line terminator (\r\n) Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules#here-strings https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules#here-strings
*/ */
function mergeHereStrings(code: string) { function mergeHereStrings(code: string) {
const regex = /@(['"])\s*(?:\r\n|\r|\n)((.|\n|\r)+?)(\r\n|\r|\n)\1@/g; const regex = /@(['"])\s*(?:\r\n|\r|\n)((.|\n|\r)+?)(\r\n|\r|\n)\1@/g;
return code.replaceAll(regex, (_$, quotes, scope) => { return code.replaceAll(regex, (_$, quotes, scope) => {
const newString = getHereStringHandler(quotes); const newString = getHereStringHandler(quotes);
const escaped = scope.replaceAll(quotes, newString.escapedQuotes); const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
const lines = getLines(escaped); const lines = getLines(escaped);
const inlined = lines.join(newString.separator); const inlined = lines.join(newString.separator);
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`; const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
return quoted; return quoted;
}); });
} }
interface IInlinedHereString { interface IInlinedHereString {
readonly quotesAround: string; readonly quotesAround: string;
readonly escapedQuotes: string; readonly escapedQuotes: string;
readonly separator: string; readonly separator: string;
} }
// We handle @' and @" differently so single quotes are interpreted literally and doubles are expandable
function getHereStringHandler(quotes: string): IInlinedHereString { function getHereStringHandler(quotes: string): IInlinedHereString {
/* const expandableNewLine = '`r`n';
We handle @' and @" differently. switch (quotes) {
Single quotes are interpreted literally and doubles are expandable. case '\'':
*/ return {
const expandableNewLine = '`r`n'; quotesAround: '\'',
switch (quotes) { escapedQuotes: '\'\'',
case '\'': separator: `\'+"${expandableNewLine}"+\'`,
return { };
quotesAround: '\'', case '"':
escapedQuotes: '\'\'', return {
separator: `'+"${expandableNewLine}"+'`, quotesAround: '"',
}; escapedQuotes: '`"',
case '"': separator: expandableNewLine,
return { };
quotesAround: '"', default:
escapedQuotes: '`"', throw new Error(`expected quotes: ${quotes}`);
separator: expandableNewLine, }
};
default:
throw new Error(`expected quotes: ${quotes}`);
}
} }
/* /*
Input -> Input ->
Get-Service * ` Get-Service * `
Sort-Object StartType ` Sort-Object StartType `
Format-Table Name, ServiceType, Status -AutoSize Format-Table Name, ServiceType, Status -AutoSize
Output -> Output ->
Get-Service * | Sort-Object StartType | Format-Table -AutoSize Get-Service * | Sort-Object StartType | Format-Table -AutoSize
*/ */
function mergeLinesWithBacktick(code: string) { function mergeLinesWithBacktick(code: string) {
/* /*
The regex actually wraps any whitespace character after backtick and before newline The regex actually wraps any whitespace character after backtick and before newline
However, this is not always the case for PowerShell. However, this is not always the case for PowerShell.
I see two behaviors: I see two behaviors:
1. If inside string, it's accepted (inside " or ') 1. If inside string, it's accepted (inside " or ')
2. If part of a command, PowerShell throws "An empty pipe element is not allowed" 2. If part of a command, PowerShell throws "An empty pipe element is not allowed"
However we don't need to be so robust and handle this complexity (yet), so for easier regex However we don't need to be so robust and handle this complexity (yet), so for easier regex
we wrap it anyway we wrap it anyway
*/ */
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' '); return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
}
function mergeNewLines(code: string) {
return getLines(code)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('; ');
} }

View File

@@ -3,48 +3,45 @@ import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes'; import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
const RegisteredPipes = [ const RegisteredPipes = [
new EscapeDoubleQuotes(), new EscapeDoubleQuotes(),
new InlinePowerShell(), new InlinePowerShell(),
]; ];
export interface IPipeFactory { export interface IPipeFactory {
get(pipeName: string): IPipe; get(pipeName: string): IPipe;
} }
export class PipeFactory implements IPipeFactory { export class PipeFactory implements IPipeFactory {
private readonly pipes = new Map<string, IPipe>(); private readonly pipes = new Map<string, IPipe>();
constructor(pipes: readonly IPipe[] = RegisteredPipes) {
constructor(pipes: readonly IPipe[] = RegisteredPipes) { if (pipes.some((pipe) => !pipe)) {
if (pipes.some((pipe) => !pipe)) { throw new Error('undefined pipe in list');
throw new Error('undefined pipe in list'); }
for (const pipe of pipes) {
this.registerPipe(pipe);
}
} }
for (const pipe of pipes) { public get(pipeName: string): IPipe {
this.registerPipe(pipe); validatePipeName(pipeName);
if (!this.pipes.has(pipeName)) {
throw new Error(`Unknown pipe: "${pipeName}"`);
}
return this.pipes.get(pipeName);
} }
} private registerPipe(pipe: IPipe): void {
validatePipeName(pipe.name);
public get(pipeName: string): IPipe { if (this.pipes.has(pipe.name)) {
validatePipeName(pipeName); throw new Error(`Pipe name must be unique: "${pipe.name}"`);
if (!this.pipes.has(pipeName)) { }
throw new Error(`Unknown pipe: "${pipeName}"`); this.pipes.set(pipe.name, pipe);
} }
return this.pipes.get(pipeName);
}
private registerPipe(pipe: IPipe): void {
validatePipeName(pipe.name);
if (this.pipes.has(pipe.name)) {
throw new Error(`Pipe name must be unique: "${pipe.name}"`);
}
this.pipes.set(pipe.name, pipe);
}
} }
function validatePipeName(name: string) { function validatePipeName(name: string) {
if (!name) { if (!name) {
throw new Error('empty pipe name'); throw new Error('empty pipe name');
} }
if (!/^[a-z][A-Za-z]*$/.test(name)) { if (!/^[a-z][A-Za-z]*$/.test(name)) {
throw new Error(`Pipe name should be camelCase: "${name}"`); throw new Error(`Pipe name should be camelCase: "${name}"`);
} }
} }

View File

@@ -2,30 +2,30 @@ import { IPipeFactory, PipeFactory } from './PipeFactory';
import { IPipelineCompiler } from './IPipelineCompiler'; import { IPipelineCompiler } from './IPipelineCompiler';
export class PipelineCompiler implements IPipelineCompiler { export class PipelineCompiler implements IPipelineCompiler {
constructor(private readonly factory: IPipeFactory = new PipeFactory()) { } constructor(private readonly factory: IPipeFactory = new PipeFactory()) { }
public compile(value: string, pipeline: string): string {
public compile(value: string, pipeline: string): string { ensureValidArguments(value, pipeline);
ensureValidArguments(value, pipeline); const pipeNames = extractPipeNames(pipeline);
const pipeNames = extractPipeNames(pipeline); const pipes = pipeNames.map((pipeName) => this.factory.get(pipeName));
const pipes = pipeNames.map((pipeName) => this.factory.get(pipeName)); for (const pipe of pipes) {
return pipes.reduce((previousValue, pipe) => { value = pipe.apply(value);
return pipe.apply(previousValue); }
}, value); return value;
} }
} }
function extractPipeNames(pipeline: string): string[] { function extractPipeNames(pipeline: string): string[] {
return pipeline return pipeline
.trim() .trim()
.split('|') .split('|')
.slice(1) .slice(1)
.map((p) => p.trim()); .map((p) => p.trim());
} }
function ensureValidArguments(value: string, pipeline: string) { function ensureValidArguments(value: string, pipeline: string) {
if (!value) { throw new Error('undefined value'); } if (!value) { throw new Error('undefined value'); }
if (!pipeline) { throw new Error('undefined pipeline'); } if (!pipeline) { throw new Error('undefined pipeline'); }
if (!pipeline.trimStart().startsWith('|')) { if (!pipeline.trimStart().startsWith('|')) {
throw new Error('pipeline does not start with pipe'); throw new Error('pipeline does not start with pipe');
} }
} }

View File

@@ -1,28 +1,28 @@
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser'; import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder'; import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class ParameterSubstitutionParser extends RegexParser { export class ParameterSubstitutionParser extends RegexParser {
protected readonly regex = new ExpressionRegexBuilder() protected readonly regex = new ExpressionRegexBuilder()
.expectExpressionStart() .expectExpressionStart()
.expectCharacters('$') .expectCharacters('$')
.matchUntilFirstWhitespace() // First match: Parameter name .matchUntilFirstWhitespace() // First match: Parameter name
.matchPipeline() // Second match: Pipeline .matchPipeline() // Second match: Pipeline
.expectExpressionEnd() .expectExpressionEnd()
.buildRegExp(); .buildRegExp();
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1]; const parameterName = match[1];
const pipeline = match[2]; const pipeline = match[2];
return { return {
parameters: [new FunctionParameter(parameterName, false)], parameters: [ new FunctionParameter(parameterName, false) ],
evaluator: (context) => { evaluator: (context) => {
const { argumentValue } = context.args.getArgument(parameterName); const argumentValue = context.args.getArgument(parameterName).argumentValue;
if (!pipeline) { if (!pipeline) {
return argumentValue; return argumentValue;
} }
return context.pipelineCompiler.compile(argumentValue, pipeline); return context.pipelineCompiler.compile(argumentValue, pipeline);
}, },
}; };
} }
} }

View File

@@ -1,59 +1,58 @@
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser'; import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder'; import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class WithParser extends RegexParser { export class WithParser extends RegexParser {
protected readonly regex = new ExpressionRegexBuilder() protected readonly regex = new ExpressionRegexBuilder()
// {{ with $parameterName }} // {{ with $parameterName }}
.expectExpressionStart() .expectExpressionStart()
.expectCharacters('with') .expectCharacters('with')
.expectOneOrMoreWhitespaces() .expectOneOrMoreWhitespaces()
.expectCharacters('$') .expectCharacters('$')
.matchUntilFirstWhitespace() // First match: parameter name .matchUntilFirstWhitespace() // First match: parameter name
.expectExpressionEnd() .expectExpressionEnd()
// ... // ...
.matchAnythingExceptSurroundingWhitespaces() // Second match: Scope text .matchAnythingExceptSurroundingWhitespaces() // Second match: Scope text
// {{ end }} // {{ end }}
.expectExpressionStart() .expectExpressionStart()
.expectCharacters('end') .expectCharacters('end')
.expectExpressionEnd() .expectExpressionEnd()
.buildRegExp(); .buildRegExp();
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1]; const parameterName = match[1];
const scopeText = match[2]; const scopeText = match[2];
return { return {
parameters: [new FunctionParameter(parameterName, true)], parameters: [ new FunctionParameter(parameterName, true) ],
evaluator: (context) => { evaluator: (context) => {
const argumentValue = context.args.hasArgument(parameterName) const argumentValue = context.args.hasArgument(parameterName) ?
? context.args.getArgument(parameterName).argumentValue context.args.getArgument(parameterName).argumentValue
: undefined; : undefined;
if (!argumentValue) { if (!argumentValue) {
return ''; return '';
} }
return replaceEachScopeSubstitution(scopeText, (pipeline) => { return replaceEachScopeSubstitution(scopeText, (pipeline) => {
if (!pipeline) { if (!pipeline) {
return argumentValue; return argumentValue;
} }
return context.pipelineCompiler.compile(argumentValue, pipeline); return context.pipelineCompiler.compile(argumentValue, pipeline);
}); });
}, },
}; };
} }
} }
const ScopeSubstitutionRegEx = new ExpressionRegexBuilder() const ScopeSubstitutionRegEx = new ExpressionRegexBuilder()
// {{ . | pipeName }} // {{ . | pipeName }}
.expectExpressionStart() .expectExpressionStart()
.expectCharacters('.') .expectCharacters('.')
.matchPipeline() // First match: pipeline .matchPipeline() // First match: pipeline
.expectExpressionEnd() .expectExpressionEnd()
.buildRegExp(); .buildRegExp();
function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) { function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) {
// Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets, // Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets, but let pipeline compiler fail on those
// but instead letting the pipeline compiler to fail on those. return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1 ) => {
return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1) => { return replacer(match1);
return replacer(match1); });
});
} }

View File

@@ -1,14 +1,13 @@
import { ensureValidParameterName } from '../../Shared/ParameterNameValidator';
import { IFunctionCallArgument } from './IFunctionCallArgument'; import { IFunctionCallArgument } from './IFunctionCallArgument';
import { ensureValidParameterName } from '../../Shared/ParameterNameValidator';
export class FunctionCallArgument implements IFunctionCallArgument { export class FunctionCallArgument implements IFunctionCallArgument {
constructor( constructor(
public readonly parameterName: string, public readonly parameterName: string,
public readonly argumentValue: string, public readonly argumentValue: string) {
) { ensureValidParameterName(parameterName);
ensureValidParameterName(parameterName); if (!argumentValue) {
if (!argumentValue) { throw new Error(`undefined argument value for "${parameterName}"`);
throw new Error(`undefined argument value for "${parameterName}"`); }
} }
}
} }

View File

@@ -2,37 +2,33 @@ import { IFunctionCallArgument } from './IFunctionCallArgument';
import { IFunctionCallArgumentCollection } from './IFunctionCallArgumentCollection'; import { IFunctionCallArgumentCollection } from './IFunctionCallArgumentCollection';
export class FunctionCallArgumentCollection implements IFunctionCallArgumentCollection { export class FunctionCallArgumentCollection implements IFunctionCallArgumentCollection {
private readonly arguments = new Map<string, IFunctionCallArgument>(); private readonly arguments = new Map<string, IFunctionCallArgument>();
public addArgument(argument: IFunctionCallArgument): void {
public addArgument(argument: IFunctionCallArgument): void { if (!argument) {
if (!argument) { throw new Error('undefined argument');
throw new Error('undefined argument'); }
if (this.hasArgument(argument.parameterName)) {
throw new Error(`argument value for parameter ${argument.parameterName} is already provided`);
}
this.arguments.set(argument.parameterName, argument);
} }
if (this.hasArgument(argument.parameterName)) { public getAllParameterNames(): string[] {
throw new Error(`argument value for parameter ${argument.parameterName} is already provided`); return Array.from(this.arguments.keys());
} }
this.arguments.set(argument.parameterName, argument); public hasArgument(parameterName: string): boolean {
} if (!parameterName) {
throw new Error('undefined parameter name');
public getAllParameterNames(): string[] { }
return Array.from(this.arguments.keys()); return this.arguments.has(parameterName);
}
public hasArgument(parameterName: string): boolean {
if (!parameterName) {
throw new Error('undefined parameter name');
} }
return this.arguments.has(parameterName); public getArgument(parameterName: string): IFunctionCallArgument {
} if (!parameterName) {
throw new Error('undefined parameter name');
public getArgument(parameterName: string): IFunctionCallArgument { }
if (!parameterName) { const arg = this.arguments.get(parameterName);
throw new Error('undefined parameter name'); if (!arg) {
throw new Error(`parameter does not exist: ${parameterName}`);
}
return arg;
} }
const arg = this.arguments.get(parameterName);
if (!arg) {
throw new Error(`parameter does not exist: ${parameterName}`);
}
return arg;
}
} }

View File

@@ -1,4 +1,4 @@
export interface IFunctionCallArgument { export interface IFunctionCallArgument {
readonly parameterName: string; readonly parameterName: string;
readonly argumentValue: string; readonly argumentValue: string;
} }

View File

@@ -1,11 +1,11 @@
import { IFunctionCallArgument } from './IFunctionCallArgument'; import { IFunctionCallArgument } from './IFunctionCallArgument';
export interface IReadOnlyFunctionCallArgumentCollection { export interface IReadOnlyFunctionCallArgumentCollection {
getArgument(parameterName: string): IFunctionCallArgument; getArgument(parameterName: string): IFunctionCallArgument;
getAllParameterNames(): string[]; getAllParameterNames(): string[];
hasArgument(parameterName: string): boolean; hasArgument(parameterName: string): boolean;
} }
export interface IFunctionCallArgumentCollection extends IReadOnlyFunctionCallArgumentCollection { export interface IFunctionCallArgumentCollection extends IReadOnlyFunctionCallArgumentCollection {
addArgument(argument: IFunctionCallArgument): void; addArgument(argument: IFunctionCallArgument): void;
} }

View File

@@ -1,149 +1,139 @@
import { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/IFunctionCallArgumentCollection'; import { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/IFunctionCallArgumentCollection';
import { IFunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/IFunctionCall'; import { ICompiledCode } from './ICompiledCode';
import { FunctionCallArgument } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument';
import { ISharedFunctionCollection } from '../../ISharedFunctionCollection'; import { ISharedFunctionCollection } from '../../ISharedFunctionCollection';
import { IFunctionCallCompiler } from './IFunctionCallCompiler';
import { IExpressionsCompiler } from '../../../Expressions/IExpressionsCompiler'; import { IExpressionsCompiler } from '../../../Expressions/IExpressionsCompiler';
import { ExpressionsCompiler } from '../../../Expressions/ExpressionsCompiler'; import { ExpressionsCompiler } from '../../../Expressions/ExpressionsCompiler';
import { ISharedFunction, IFunctionCode } from '../../ISharedFunction'; import { ISharedFunction, IFunctionCode } from '../../ISharedFunction';
import { IFunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/IFunctionCall';
import { FunctionCall } from '../FunctionCall'; import { FunctionCall } from '../FunctionCall';
import { FunctionCallArgumentCollection } from '../Argument/FunctionCallArgumentCollection'; import { FunctionCallArgumentCollection } from '../Argument/FunctionCallArgumentCollection';
import { IFunctionCallCompiler } from './IFunctionCallCompiler'; import { FunctionCallArgument } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument';
import { ICompiledCode } from './ICompiledCode';
export class FunctionCallCompiler implements IFunctionCallCompiler { export class FunctionCallCompiler implements IFunctionCallCompiler {
public static readonly instance: IFunctionCallCompiler = new FunctionCallCompiler(); public static readonly instance: IFunctionCallCompiler = new FunctionCallCompiler();
protected constructor( protected constructor(
private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler(), private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler()) {
) { }
}
public compileCall( public compileCall(
calls: IFunctionCall[], calls: IFunctionCall[],
functions: ISharedFunctionCollection, functions: ISharedFunctionCollection): ICompiledCode {
): ICompiledCode { if (!functions) { throw new Error('undefined functions'); }
if (!functions) { throw new Error('undefined functions'); } if (!calls) { throw new Error('undefined calls'); }
if (!calls) { throw new Error('undefined calls'); } if (calls.some((f) => !f)) { throw new Error('undefined function call'); }
if (calls.some((f) => !f)) { throw new Error('undefined function call'); } const context: ICompilationContext = {
const context: ICompilationContext = { allFunctions: functions,
allFunctions: functions, callSequence: calls,
callSequence: calls, expressionsCompiler: this.expressionsCompiler,
expressionsCompiler: this.expressionsCompiler, };
}; const code = compileCallSequence(context);
const code = compileCallSequence(context); return code;
return code; }
}
} }
interface ICompilationContext { interface ICompilationContext {
allFunctions: ISharedFunctionCollection; allFunctions: ISharedFunctionCollection;
callSequence: readonly IFunctionCall[]; callSequence: readonly IFunctionCall[];
expressionsCompiler: IExpressionsCompiler; expressionsCompiler: IExpressionsCompiler;
} }
interface ICompiledFunctionCall { interface ICompiledFunctionCall {
readonly code: string; readonly code: string;
readonly revertCode: string; readonly revertCode: string;
} }
function compileCallSequence(context: ICompilationContext): ICompiledFunctionCall { function compileCallSequence(context: ICompilationContext): ICompiledFunctionCall {
const compiledFunctions = context.callSequence const compiledFunctions = new Array<ICompiledFunctionCall>();
.flatMap((call) => compileSingleCall(call, context)); for (const call of context.callSequence) {
return { const compiledCode = compileSingleCall(call, context);
code: merge(compiledFunctions.map((f) => f.code)), compiledFunctions.push(...compiledCode);
revertCode: merge(compiledFunctions.map((f) => f.revertCode)), }
}; return {
code: merge(compiledFunctions.map((f) => f.code)),
revertCode: merge(compiledFunctions.map((f) => f.revertCode)),
};
} }
function compileSingleCall( function compileSingleCall(call: IFunctionCall, context: ICompilationContext): ICompiledFunctionCall[] {
call: IFunctionCall, const func = context.allFunctions.getFunctionByName(call.functionName);
context: ICompilationContext, ensureThatCallArgumentsExistInParameterDefinition(func, call.args);
): ICompiledFunctionCall[] { if (func.body.code) { // Function with inline code
const func = context.allFunctions.getFunctionByName(call.functionName); const compiledCode = compileCode(func.body.code, call.args, context.expressionsCompiler);
ensureThatCallArgumentsExistInParameterDefinition(func, call.args); return [ compiledCode ];
if (func.body.code) { // Function with inline code } else { // Function with inner calls
const compiledCode = compileCode(func.body.code, call.args, context.expressionsCompiler); return func.body.calls
return [compiledCode]; .map((innerCall) => {
} const compiledArgs = compileArgs(innerCall.args, call.args, context.expressionsCompiler);
// Function with inner calls const compiledCall = new FunctionCall(innerCall.functionName, compiledArgs);
return func.body.calls return compileSingleCall(compiledCall, context);
.map((innerCall) => { })
const compiledArgs = compileArgs(innerCall.args, call.args, context.expressionsCompiler); .flat();
const compiledCall = new FunctionCall(innerCall.functionName, compiledArgs); }
return compileSingleCall(compiledCall, context);
})
.flat();
} }
function compileCode( function compileCode(
code: IFunctionCode, code: IFunctionCode,
args: IReadOnlyFunctionCallArgumentCollection, args: IReadOnlyFunctionCallArgumentCollection,
compiler: IExpressionsCompiler, compiler: IExpressionsCompiler): ICompiledFunctionCall {
): ICompiledFunctionCall { return {
return { code: compiler.compileExpressions(code.do, args),
code: compiler.compileExpressions(code.do, args), revertCode: compiler.compileExpressions(code.revert, args),
revertCode: compiler.compileExpressions(code.revert, args), };
};
} }
function compileArgs( function compileArgs(
argsToCompile: IReadOnlyFunctionCallArgumentCollection, argsToCompile: IReadOnlyFunctionCallArgumentCollection,
args: IReadOnlyFunctionCallArgumentCollection, args: IReadOnlyFunctionCallArgumentCollection,
compiler: IExpressionsCompiler, compiler: IExpressionsCompiler,
): IReadOnlyFunctionCallArgumentCollection { ): IReadOnlyFunctionCallArgumentCollection {
return argsToCompile const compiledArgs = new FunctionCallArgumentCollection();
.getAllParameterNames() for (const parameterName of argsToCompile.getAllParameterNames()) {
.map((parameterName) => { const argumentValue = argsToCompile.getArgument(parameterName).argumentValue;
const { argumentValue } = argsToCompile.getArgument(parameterName); const compiledValue = compiler.compileExpressions(argumentValue, args);
const compiledValue = compiler.compileExpressions(argumentValue, args); const newArgument = new FunctionCallArgument(parameterName, compiledValue);
return new FunctionCallArgument(parameterName, compiledValue); compiledArgs.addArgument(newArgument);
}) }
.reduce((compiledArgs, arg) => { return compiledArgs;
compiledArgs.addArgument(arg);
return compiledArgs;
}, new FunctionCallArgumentCollection());
} }
function merge(codeParts: readonly string[]): string { function merge(codeParts: readonly string[]): string {
return codeParts return codeParts
.filter((part) => part?.length > 0) .filter((part) => part?.length > 0)
.join('\n'); .join('\n');
} }
function ensureThatCallArgumentsExistInParameterDefinition( function ensureThatCallArgumentsExistInParameterDefinition(
func: ISharedFunction, func: ISharedFunction,
args: IReadOnlyFunctionCallArgumentCollection, args: IReadOnlyFunctionCallArgumentCollection): void {
): void { const callArgumentNames = args.getAllParameterNames();
const callArgumentNames = args.getAllParameterNames(); const functionParameterNames = func.parameters.all.map((param) => param.name) || [];
const functionParameterNames = func.parameters.all.map((param) => param.name) || []; const unexpectedParameters = findUnexpectedParameters(callArgumentNames, functionParameterNames);
const unexpectedParameters = findUnexpectedParameters(callArgumentNames, functionParameterNames); throwIfNotEmpty(func.name, unexpectedParameters, functionParameterNames);
throwIfNotEmpty(func.name, unexpectedParameters, functionParameterNames);
} }
function findUnexpectedParameters( function findUnexpectedParameters(
callArgumentNames: string[], callArgumentNames: string[],
functionParameterNames: string[], functionParameterNames: string[]): string[] {
): string[] { if (!callArgumentNames.length && !functionParameterNames.length) {
if (!callArgumentNames.length && !functionParameterNames.length) { return [];
return []; }
} return callArgumentNames
return callArgumentNames .filter((callParam) => !functionParameterNames.includes(callParam));
.filter((callParam) => !functionParameterNames.includes(callParam));
} }
function throwIfNotEmpty( function throwIfNotEmpty(
functionName: string, functionName: string,
unexpectedParameters: string[], unexpectedParameters: string[],
expectedParameters: string[], expectedParameters: string[]) {
) { if (!unexpectedParameters.length) {
if (!unexpectedParameters.length) { return;
return; }
} throw new Error(
throw new Error( `Function "${functionName}" has unexpected parameter(s) provided: ` +
// eslint-disable-next-line prefer-template `"${unexpectedParameters.join('", "')}"` +
`Function "${functionName}" has unexpected parameter(s) provided: ` '. Expected parameter(s): ' +
+ `"${unexpectedParameters.join('", "')}"` (expectedParameters.length ? `"${expectedParameters.join('", "')}"` : 'none'),
+ '. Expected parameter(s): ' );
+ (expectedParameters.length ? `"${expectedParameters.join('", "')}"` : 'none'),
);
} }

View File

@@ -1,4 +1,4 @@
export interface ICompiledCode { export interface ICompiledCode {
readonly code: string; readonly code: string;
readonly revertCode?: string; readonly revertCode?: string;
} }

View File

@@ -1,9 +1,9 @@
import { ICompiledCode } from './ICompiledCode';
import { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; import { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection';
import { IFunctionCall } from '../IFunctionCall'; import { IFunctionCall } from '../IFunctionCall';
import { ICompiledCode } from './ICompiledCode';
export interface IFunctionCallCompiler { export interface IFunctionCallCompiler {
compileCall( compileCall(
calls: IFunctionCall[], calls: IFunctionCall[],
functions: ISharedFunctionCollection): ICompiledCode; functions: ISharedFunctionCollection): ICompiledCode;
} }

View File

@@ -2,15 +2,14 @@ import { IReadOnlyFunctionCallArgumentCollection } from './Argument/IFunctionCal
import { IFunctionCall } from './IFunctionCall'; import { IFunctionCall } from './IFunctionCall';
export class FunctionCall implements IFunctionCall { export class FunctionCall implements IFunctionCall {
constructor( constructor(
public readonly functionName: string, public readonly functionName: string,
public readonly args: IReadOnlyFunctionCallArgumentCollection, public readonly args: IReadOnlyFunctionCallArgumentCollection) {
) { if (!functionName) {
if (!functionName) { throw new Error('empty function name in function call');
throw new Error('empty function name in function call'); }
if (!args) {
throw new Error('undefined args');
}
} }
if (!args) {
throw new Error('undefined args');
}
}
} }

View File

@@ -1,42 +1,35 @@
import { FunctionCallData, FunctionCallsData, FunctionCallParametersData } from 'js-yaml-loader!@/*'; import { FunctionCallData, FunctionCallsData } from 'js-yaml-loader!@/*';
import { IFunctionCall } from './IFunctionCall'; import { IFunctionCall } from './IFunctionCall';
import { FunctionCallArgumentCollection } from './Argument/FunctionCallArgumentCollection'; import { FunctionCallArgumentCollection } from './Argument/FunctionCallArgumentCollection';
import { FunctionCallArgument } from './Argument/FunctionCallArgument'; import { FunctionCallArgument } from './Argument/FunctionCallArgument';
import { FunctionCall } from './FunctionCall'; import { FunctionCall } from './FunctionCall';
export function parseFunctionCalls(calls: FunctionCallsData): IFunctionCall[] { export function parseFunctionCalls(calls: FunctionCallsData): IFunctionCall[] {
if (calls === undefined) { if (!calls) {
throw new Error('undefined call data'); throw new Error('undefined call data');
} }
const sequence = getCallSequence(calls); const sequence = getCallSequence(calls);
return sequence.map((call) => parseFunctionCall(call)); return sequence.map((call) => parseFunctionCall(call));
} }
function getCallSequence(calls: FunctionCallsData): FunctionCallData[] { function getCallSequence(calls: FunctionCallsData): FunctionCallData[] {
if (typeof calls !== 'object') { if (typeof calls !== 'object') {
throw new Error('called function(s) must be an object'); throw new Error('called function(s) must be an object');
} }
if (calls instanceof Array) { if (calls instanceof Array) {
return calls as FunctionCallData[]; return calls as FunctionCallData[];
} }
return [calls as FunctionCallData]; return [ calls as FunctionCallData ];
} }
function parseFunctionCall(call: FunctionCallData): IFunctionCall { function parseFunctionCall(call: FunctionCallData): IFunctionCall {
if (!call) { if (!call) {
throw new Error('undefined function call'); throw new Error(`undefined function call`);
} }
const callArgs = parseArgs(call.parameters); const args = new FunctionCallArgumentCollection();
return new FunctionCall(call.function, callArgs); for (const parameterName of Object.keys(call.parameters || {})) {
} const arg = new FunctionCallArgument(parameterName, call.parameters[parameterName]);
args.addArgument(arg);
function parseArgs( }
parameters: FunctionCallParametersData, return new FunctionCall(call.function, args);
): FunctionCallArgumentCollection {
return Object.keys(parameters || {})
.map((parameterName) => new FunctionCallArgument(parameterName, parameters[parameterName]))
.reduce((args, arg) => {
args.addArgument(arg);
return args;
}, new FunctionCallArgumentCollection());
} }

View File

@@ -1,6 +1,6 @@
import { IReadOnlyFunctionCallArgumentCollection } from './Argument/IFunctionCallArgumentCollection'; import { IReadOnlyFunctionCallArgumentCollection } from './Argument/IFunctionCallArgumentCollection';
export interface IFunctionCall { export interface IFunctionCall {
readonly functionName: string; readonly functionName: string;
readonly args: IReadOnlyFunctionCallArgumentCollection; readonly args: IReadOnlyFunctionCallArgumentCollection;
} }

View File

@@ -1,24 +1,24 @@
import { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection'; import { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection';
import { IFunctionCall } from './Call/IFunctionCall'; import { IFunctionCall } from '../Function/Call/IFunctionCall';
export interface ISharedFunction { export interface ISharedFunction {
readonly name: string; readonly name: string;
readonly parameters: IReadOnlyFunctionParameterCollection; readonly parameters: IReadOnlyFunctionParameterCollection;
readonly body: ISharedFunctionBody; readonly body: ISharedFunctionBody;
} }
export interface ISharedFunctionBody { export interface ISharedFunctionBody {
readonly type: FunctionBodyType; readonly type: FunctionBodyType;
readonly code: IFunctionCode; readonly code: IFunctionCode;
readonly calls: readonly IFunctionCall[]; readonly calls: readonly IFunctionCall[];
} }
export enum FunctionBodyType { export enum FunctionBodyType {
Code, Code,
Calls, Calls,
} }
export interface IFunctionCode { export interface IFunctionCode {
readonly do: string; readonly do: string;
readonly revert?: string; readonly revert?: string;
} }

View File

@@ -1,5 +1,5 @@
import { ISharedFunction } from './ISharedFunction'; import { ISharedFunction } from './ISharedFunction';
export interface ISharedFunctionCollection { export interface ISharedFunctionCollection {
getFunctionByName(name: string): ISharedFunction; getFunctionByName(name: string): ISharedFunction;
} }

View File

@@ -2,5 +2,5 @@ import { FunctionData } from 'js-yaml-loader!@/*';
import { ISharedFunctionCollection } from './ISharedFunctionCollection'; import { ISharedFunctionCollection } from './ISharedFunctionCollection';
export interface ISharedFunctionsParser { export interface ISharedFunctionsParser {
parseFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection; parseFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection;
} }

View File

@@ -1,11 +1,10 @@
import { ensureValidParameterName } from '../Shared/ParameterNameValidator';
import { IFunctionParameter } from './IFunctionParameter'; import { IFunctionParameter } from './IFunctionParameter';
import { ensureValidParameterName } from '../Shared/ParameterNameValidator';
export class FunctionParameter implements IFunctionParameter { export class FunctionParameter implements IFunctionParameter {
constructor( constructor(
public readonly name: string, public readonly name: string,
public readonly isOptional: boolean, public readonly isOptional: boolean) {
) { ensureValidParameterName(name);
ensureValidParameterName(name); }
}
} }

Some files were not shown because too many files have changed in this diff Show More