Compare commits

..

1 Commits

Author SHA1 Message Date
undergroundwires
704a3d0417 Fix card height inconsistency
TODO: Is CardLayout, card-layout, cardLayout etc., best names possiuble?

Fix card expansion panel heights not being equal. This is due to
limitations in CSS flex view.

Heights of all cards are `100%` which gives them uniform loook on same
row with all equal heights. However, their heights are set to `auto`
when of the cards are open. In that case, their sizes are no longer
equal in same row, cards with longer text/titles keeping more space.
This is because when a card is open, its expansion panel grows in its
own DOM element, increasing the height of the card.
at same height on each line, their heights change completely when one of

Heights gets fucked up when card is collapsing, this is big fix, fix this.

Supporting changes:

- Move card expander to its own component `CardExpansionPanel`.
- Introduce `UseCardLayout` hook to do calculations instead of CSS to
  circumvent limitations in CSS flex view.
2024-05-28 16:20:20 +02:00
596 changed files with 16554 additions and 43748 deletions

View File

@@ -1,12 +0,0 @@
inputs:
project-root:
required: false
default: '.'
runs:
using: composite
steps:
-
name: Install ImageMagick
shell: bash
run: ./.github/actions/install-imagemagick/install-imagemagick.sh
working-directory: ${{ inputs.project-root }}

View File

@@ -1,56 +0,0 @@
#!/usr/bin/env bash
main() {
local install_command
if ! install_command=$(get_install_command); then
fatal_error 'Could not find available command to install'
fi
if ! eval "$install_command"; then
echo "Failed to install ImageMagick. Command: ${install_command}"
exit 1
fi
echo 'ImageMagick installation completed successfully'
}
get_install_command() {
case "$OSTYPE" in
darwin*)
ensure_command_exists 'brew'
echo 'brew install imagemagick'
;;
linux-gnu*)
if is_ubuntu; then
ensure_command_exists 'apt'
echo 'sudo apt install -y imagemagick'
else
fatal_error 'Unsupported Linux distribution'
fi
;;
msys*|cygwin*)
ensure_command_exists 'choco'
echo 'choco install -y imagemagick'
;;
*)
fatal_error "Unsupported operating system: $OSTYPE"
;;
esac
}
ensure_command_exists() {
local -r command="$1"
if ! command -v "$command" >/dev/null 2>&1; then
fatal_error "Command missing: $command"
fi
}
fatal_error() {
local -r error_message="$1"
>&2 echo "$error_message"
exit 1
}
is_ubuntu() {
[ -f /etc/os-release ] && grep -qi 'ubuntu' /etc/os-release
}
main

View File

@@ -26,12 +26,9 @@ jobs:
-
name: Install dependencies
uses: ./.github/actions/npm-install-dependencies
-
name: Install ImageMagick # For screenshots
uses: ./.github/actions/install-imagemagick
-
name: Configure Ubuntu
if: contains(matrix.os, 'ubuntu')
if: contains(matrix.os, 'ubuntu') # macOS runner is missing Docker
shell: bash
run: |-
sudo apt update
@@ -59,20 +56,11 @@ jobs:
sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
echo "DISPLAY=:99" >> $GITHUB_ENV
# Install ImageMagick for screenshots
sudo apt install -y imagemagick
# Install xdotool and xprop (from x11-utils) for window title capturing
sudo apt install -y xdotool x11-utils
# Workaround for Electron AppImage apps failing to initialize on Ubuntu 24.04 due to AppArmor restrictions
# Disables unprivileged user namespaces restriction to allow Electron apps to run
# Reference: https://github.com/electron/electron/issues/42510
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# Workaround for Mesa driver issues on Ubuntu 24.04
# Installs latest Mesa drivers from Kisak PPA
# Reference: https://askubuntu.com/q/1516040
sudo add-apt-repository ppa:kisak/kisak-mesa
sudo apt update
sudo apt upgrade
-
name: Test
shell: bash

View File

@@ -11,9 +11,8 @@ jobs:
- npm run lint:eslint
- npm run lint:yaml
- npm run lint:md
- npm run lint:md:consistency
- npm run lint:md:relative-urls
- npm run lint:md:external-urls
- npm run lint:md:consistency
os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations
steps:
@@ -75,28 +74,3 @@ jobs:
-
name: Analyzing the code with pylint
run: npm run lint:pylint
validate-collection-files:
runs-on: ${{ matrix.os }}-latest
strategy:
matrix:
os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations
steps:
-
name: Checkout
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node
-
name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
-
name: Install dependencies
run: python3 -m pip install -r ./scripts/validate-collections-yaml/requirements.txt
-
name: Validate
run: python3 ./scripts/validate-collections-yaml

View File

@@ -9,15 +9,16 @@ jobs:
runs-on: ${{ matrix.os }}-latest
strategy:
matrix:
os: [macos, ubuntu, windows]
os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations
steps:
-
name: Checkout
uses: actions/checkout@v4
-
name: Install ImageMagick
uses: ./.github/actions/install-imagemagick
name: Install ImageMagick on macOS
if: matrix.os == 'macos'
run: brew install imagemagick
-
name: Setup node
uses: ./.github/actions/setup-node

4
.gitignore vendored
View File

@@ -14,7 +14,3 @@ node_modules
# macOS
.DS_Store
# Python
__pycache__
.venv

View File

@@ -5,10 +5,8 @@
"wengerk.highlight-bad-chars", // Highlights bad chars.
"wayou.vscode-todo-highlight", // Highlights TODO.
"wix.vscode-import-cost", // Shows in KB how much a require include in code.
// Markdown
// Documentation
"davidanson.vscode-markdownlint", // Lints markdown.
// YAML
"redhat.vscode-yaml", // Lints YAML files, validates against schema.
// TypeScript / JavaScript
"dbaeumer.vscode-eslint", // Lints JavaScript/TypeScript.
"pmneo.tsimporter", // Provides better auto-complete for TypeScripts imports.

View File

@@ -1,65 +1,5 @@
# Changelog
## 0.13.6 (2024-08-13)
* win: improve service disabling as TrustedInstaller | [5d365f6](https://github.com/undergroundwires/privacy.sexy/commit/5d365f65fa0e34925b16b2eac2af53c31e34e99a)
* Fix documentation button spacing on small screens | [70959cc](https://github.com/undergroundwires/privacy.sexy/commit/70959ccadafac5abcfa83e90cdb0537890b05f14)
* Fix close button overlap by scrollbar | [19ea8db](https://github.com/undergroundwires/privacy.sexy/commit/19ea8dbc5bc2dc436200cd40bf2a84c3fc3c6471)
* win: refactor version-specific actions | [0239b52](https://github.com/undergroundwires/privacy.sexy/commit/0239b523859d5c2b80033cc03f0248a9af35f28f)
* win: support Microsoft Store Firefox installations | [8d7a7eb](https://github.com/undergroundwires/privacy.sexy/commit/8d7a7eb434b2d83e32fa758db7e6798849bad41c)
* Refactor text utilities and expand their usage | [851917e](https://github.com/undergroundwires/privacy.sexy/commit/851917e049c41c679644ddbe8ad4b6e45e5c8f35)
* Bump dependencies to latest | [dd7239b](https://github.com/undergroundwires/privacy.sexy/commit/dd7239b8c14027274926279a4c8c7e5845b55558)
* Refactor styles to match new CSS nesting behavior | [abe03ce](https://github.com/undergroundwires/privacy.sexy/commit/abe03cef3f691f6e56faee991cd2da9c45244279)
* Improve compiler error display for latest Chromium | [b16e136](https://github.com/undergroundwires/privacy.sexy/commit/b16e13678ce1b8a6871eba8196e82bb321410067)
* Fix intermittent `ModalDialog` unit test failures | [a650558](https://github.com/undergroundwires/privacy.sexy/commit/a6505587bf4a448f5f3de930004a95ee203416b8)
* Ensure tests do not log warning or errors | [ae0165f](https://github.com/undergroundwires/privacy.sexy/commit/ae0165f1fe7dba9dd8ddaa1afa722a939772d3b6)
* win: improve disabling SmartScreen #385 | [11e566d](https://github.com/undergroundwires/privacy.sexy/commit/11e566d0e5177214a2600f3fd2097aea62373b24)
* win: unify registry setting as TrustedInstaller | [8526d25](https://github.com/undergroundwires/privacy.sexy/commit/8526d2510b34cbd7e79342f79d444419f601b186)
* win: improve, fix, restructure CEIP disabling | [c2d3cdd](https://github.com/undergroundwires/privacy.sexy/commit/c2d3cddc47d8d4b34bff63d959612919fa971012)
* win: centralize, improve Defender data collection | [b185255](https://github.com/undergroundwires/privacy.sexy/commit/b185255a0a72d5bfa96d6cf60f868ecc67149d68)
* win: fix and document VStudio license removal | [109fc01](https://github.com/undergroundwires/privacy.sexy/commit/109fc01c9a047002c4309e7f8a2ca4647c494a8a)
* win: improve registry/recent cleaning | [48d97af](https://github.com/undergroundwires/privacy.sexy/commit/48d97afdf6c2964cab7951208e1b0a02c3fd4c9b)
* Relax linting to allow null recommendation | [6fbc816](https://github.com/undergroundwires/privacy.sexy/commit/6fbc81675f7f063c4ee2502b8d9f169aacb39ae4)
* Refactor executable IDs to use strings #262 | [ded55a6](https://github.com/undergroundwires/privacy.sexy/commit/ded55a66d6044a03d4e18330e146b69d159509a3)
* win: fix, improve and unify Windows version logic | [f89c232](https://github.com/undergroundwires/privacy.sexy/commit/f89c2322b05d19b82914b20416ecefd7bc7e3702)
* Fix PowerShell code block inlining in compiler | [d77c3cb](https://github.com/undergroundwires/privacy.sexy/commit/d77c3cbbe212d9929e083181cc331b45d01e2883)
* win: improve registry value deletion #381 | [55c23e9](https://github.com/undergroundwires/privacy.sexy/commit/55c23e9d4cee3b7f74c26a4ac8516535048d67f2)
* win: improve folder hiding in "This PC" #16 | [e8add5e](https://github.com/undergroundwires/privacy.sexy/commit/e8add5ec08d2e8b7636cc9c8f0f9a33e4b004265)
* win: improve Microsoft Edge associations removal | [c2f4b68](https://github.com/undergroundwires/privacy.sexy/commit/c2f4b6878635e97f9c4be7bf2ee194a2deebb38a)
* win: unify registry data setting, fix #380 | [4cea6b2](https://github.com/undergroundwires/privacy.sexy/commit/4cea6b26ec2717c792c2471cc587f370274f90c4)
* win: improve disabling NCSI #189, #216, #279 | [c7e57b8](https://github.com/undergroundwires/privacy.sexy/commit/c7e57b8913f409a1c149ba598dc2f8786df0f9a9)
* win, mac: fix minor typos, formatting, dead URLs | [29e1069](https://github.com/undergroundwires/privacy.sexy/commit/29e1069bf2bc317e3c255b38c1ba0ab078b42d98)
* win: fix, constrain and document WNS #227 #314 | [50ba00b](https://github.com/undergroundwires/privacy.sexy/commit/50ba00b0af6232fc9187532635b04c4d9d9a68af)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.5...0.13.6)
## 0.13.5 (2024-06-26)
* ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703)
* win: document and improve Firefox telemetry #259 | [8341411](https://github.com/undergroundwires/privacy.sexy/commit/8341411be434c6d145e942b1792020ccf02f58c8)
* Add image to `README.md` to thank supporters | [fa2a92b](https://github.com/undergroundwires/privacy.sexy/commit/fa2a92bf893933bf5cd04512a712b7aa1b921277)
* win: improve executable blocking, Chrome reporting | [f21ef92](https://github.com/undergroundwires/privacy.sexy/commit/f21ef9250a2f459dbd4f789d857c78298fc202e6)
* mac: discourage and document captive portal script | [b29cd7b](https://github.com/undergroundwires/privacy.sexy/commit/b29cd7b5f74accf92c9700c3171670f82c8cb3b3)
* win: fix revert scripts for removing shortcuts | [8becc7d](https://github.com/undergroundwires/privacy.sexy/commit/8becc7dbc46af4441900e9841a716a53735bc82e)
* Refactor to unify scripts/categories as Executable | [c138f74](https://github.com/undergroundwires/privacy.sexy/commit/c138f74460bafaba3da55a65f3942bb6f95b1d99)
* Add object property validation in parser #369 | [6ecfa9b](https://github.com/undergroundwires/privacy.sexy/commit/6ecfa9b954edc10401acaf5c735eec0fc9f991cd)
* win: fix missing app access recommendations #369 | [1c2d82d](https://github.com/undergroundwires/privacy.sexy/commit/1c2d82dc9bd412ea601ab2550ba0b4f7d144f8e8)
* win: fix text and handwriting script omission #369 | [1a10cf2](https://github.com/undergroundwires/privacy.sexy/commit/1a10cf2e5f87cd8eb421ef77f6ce764b5482515e)
* mac: document, improve, encourage clearing logs | [e9a5285](https://github.com/undergroundwires/privacy.sexy/commit/e9a52859f63609c3f56def0b3e4d1ac6e5661536)
* Add schema validation for collection files #369 | [dc03bff](https://github.com/undergroundwires/privacy.sexy/commit/dc03bff324d673101002bb16f14e0429e8170fbb)
* win: fix incomplete VSCEIP, location scripts | [48761f6](https://github.com/undergroundwires/privacy.sexy/commit/48761f62a242f0910307994271cbe6730fb30f7e)
* Add type validation for parameters and fix types | [fac26a6](https://github.com/undergroundwires/privacy.sexy/commit/fac26a6ca07479c84fe62c5ea2a572dad1898ef8)
* Bump Electron to latest | [ed93614](https://github.com/undergroundwires/privacy.sexy/commit/ed93614ca34b1ab166e645cc5bedd497b0caeaac)
* Trim compiler error output for better readability | [78c62cf](https://github.com/undergroundwires/privacy.sexy/commit/78c62cfc953dbba543d8bdc42828a4ef4b13a7c7)
* win: fix errors due to missing Edge uninstaller | [2f82873](https://github.com/undergroundwires/privacy.sexy/commit/2f828735a87f98ba87b4fc826823d1482d4f2db2)
* win: fix latest Edge removal on Windows 10 #309 | [e7031a3](https://github.com/undergroundwires/privacy.sexy/commit/e7031a3ae4e57b6522c6ca67fc30e8a8718506b2)
* win: categorize, rename, doc Chrome & Edge scripts | [f286f92](https://github.com/undergroundwires/privacy.sexy/commit/f286f92b1fec49e89eea8982dffbc3d6ef1defde)
* win: add disabling Edge/WebView2 auto-updates #309 | [ed7e69c](https://github.com/undergroundwires/privacy.sexy/commit/ed7e69c07efe83fdb7f4af13aa220ff991fbbe59)
* win, linux, mac: fix typos #373 | [c09c5ff](https://github.com/undergroundwires/privacy.sexy/commit/c09c5ffa47865f7c76910644558b6783ed44f1e4)
* win: add more Edge scripts including AI & ads | [1430d52](https://github.com/undergroundwires/privacy.sexy/commit/1430d5215ab094d8201710761d631dc2bd740918)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.4...0.13.5)
## 0.13.4 (2024-05-27)
* Add specific empty function name compiler error | [870120b](https://github.com/undergroundwires/privacy.sexy/commit/870120bc13909a3681e0f0a2351806849476342f)

View File

@@ -122,7 +122,7 @@
## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-Setup-0.13.6.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-0.13.6.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-0.13.6.AppImage). For more options, see [here](#additional-install-options).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-Setup-0.13.4.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-0.13.4.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-0.13.4.AppImage). For more options, see [here](#additional-install-options).
See also:
@@ -186,7 +186,3 @@ Check [architecture.md](./docs/architecture.md) for an overview of design and ho
Security is a top priority at privacy.sexy.
An extensive commitment to security verification ensures this priority.
For any security concerns or vulnerabilities, please consult the [Security Policy](./SECURITY.md).
## Supporters
[![Supporters appreciation banner showing the supporters](https://undergroundwires.dev/img/supporters.jpg)](https://undergroundwires.dev/supporters)

View File

@@ -41,5 +41,5 @@ Application layer compiles templating syntax during parsing to create the end sc
The steps to extend the templating syntax:
1. Add a new parser under [SyntaxParsers](./../src/application/Parser/Executable/Script/Compiler/Expressions/SyntaxParsers) where you can look at other parsers to understand more.
2. Register your in [CompositeExpressionParser](./../src/application/Parser/Executable/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts).
1. Add a new parser under [SyntaxParsers](./../src/application/Parser/Script/Compiler/Expressions/SyntaxParsers) where you can look at other parsers to understand more.
2. Register your in [CompositeExpressionParser](./../src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts).

View File

@@ -1,11 +1,11 @@
# Collection files
privacy.sexy is a data-driven application that reads YAML files.
This document details the structure and syntax of the YAML files located in [`application/collections`](./../src/application/collections/), which form the backbone of the application's data model. The YAML schema [`.schema.yaml`](./../src/application/collections/.schema.yaml) is provided to provide better IDE support and be used in automated validations.
This document details the structure and syntax of the YAML files located in [`application/collections`](./../src/application/collections/), which form the backbone of the application's data model.
Related documentation:
- 📖 [`Collections README`](./../src/application/collections/README.md) includes references to code as documentation.
- 📖 [`collection.yaml.d.ts`](./../src/application/collections/collection.yaml.d.ts) outlines code types.
- 📖 [Script Guidelines](./script-guidelines.md) provide guidance on script creation including best-practices.
## Objects
@@ -28,22 +28,11 @@ Related documentation:
- `scripting:` ***[`ScriptingDefinition`](#scriptingdefinition)*** **(required)**
- Sets the scripting language for all inline code used within the collection.
### Executables
They represent independently executable actions with documentation and reversibility.
An Executable is a logical entity that can
- execute once compiled,
- include a `docs` property for documentation.
It's either [Category](#category) or a [Script](#script).
#### `Category`
### `Category`
Represents a logical group of scripts and subcategories.
##### `Category` syntax
#### `Category` syntax
- `category:` *`string`* **(required)**
- Name of the category.
@@ -54,7 +43,7 @@ Represents a logical group of scripts and subcategories.
- `docs`: *`string`* | `[`*`string`*`, ... ]`
- Markdown-formatted documentation related to the category.
#### `Script`
### `Script`
Represents an individual tweak.
@@ -69,7 +58,7 @@ Types (like [functions](#function)):
📖 For detailed guidelines, see [Script Guidelines](./script-guidelines.md).
##### `Script` syntax
#### `Script` syntax
- `name`: *`string`* **(required)**
- Script name.

View File

@@ -34,10 +34,8 @@ The desktop version ensures secure delivery through cryptographic signatures and
[Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy.
> **Note for macOS users:**
> On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> **Note for macOS users:** On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> Users get notified about updates but might need to complete the installation manually.
> Updater stores update installation files temporarily at `$HOME/Library/Application Support/privacy.sexy/updates`.
> Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️.
### Logging

View File

@@ -39,7 +39,6 @@ See [ci-cd.md](./ci-cd.md) for more information.
- Markdown: `npm run lint:md`
- Markdown consistency `npm run lint:md:consistency`
- Markdown relative URLs: `npm run lint:md:relative-urls`
- Markdown external URLs: `npm run lint:md:external-urls`
- JavaScript/TypeScript: `npm run lint:eslint`
- Yaml: `npm run lint:yaml`
@@ -81,10 +80,8 @@ See [ci-cd.md](./ci-cd.md) for more information.
- [**`npm run install-deps [-- <options>]`**](../scripts/npm-install.js):
- Manages NPM dependency installation, it offers capabilities like doing a fresh install, retries on network errors, and other features.
- For example, you can run `npm run install-deps -- --fresh` to do clean installation of dependencies.
- [**`python3 ./scripts/configure_vscode.py`**](../scripts/configure_vscode.py):
- [**`python ./scripts/configure_vscode.py`**](../scripts/configure_vscode.py):
- Optimizes Visual Studio Code settings and installs essential extensions, enhancing the development environment.
- [**`python3 ./scripts/validate-collections-yaml`**](../scripts/validate-collections-yaml/README.md):
- Validates the syntax and structure of collection YAML files.
#### Automation scripts

10062
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "privacy.sexy",
"version": "0.13.6",
"version": "0.13.4",
"private": true,
"slogan": "Privacy is sexy",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",
@@ -14,7 +14,7 @@
"test:integration": "vitest run --dir tests/integration",
"test:cy:run": "start-server-and-test \"vite build && vite preview --port 7070\" http://localhost:7070 \"cypress run --config baseUrl=http://localhost:7070\"",
"test:cy:open": "start-server-and-test \"vite --port 7070 --mode production\" http://localhost:7070 \"cypress open --config baseUrl=http://localhost:7070\"",
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:md:external-urls && npm run lint:eslint && npm run lint:yaml && npm run lint:pylint",
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml && npm run lint:pylint",
"install-deps": "node scripts/npm-install.js",
"icons:build": "node scripts/logo-update.js",
"check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node",
@@ -28,61 +28,60 @@
"lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links",
"lint:md:external-urls": "remark . --frail --use remark-lint-no-dead-urls",
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"lint:pylint": "pylint **/*.py",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps"
},
"dependencies": {
"@floating-ui/vue": "^1.1.1",
"@floating-ui/vue": "^1.0.6",
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.35.3",
"electron-log": "^5.1.6",
"ace-builds": "^1.33.0",
"electron-log": "^5.1.2",
"electron-progressbar": "^2.2.1",
"electron-updater": "^6.2.1",
"electron-updater": "^6.1.9",
"file-saver": "^2.0.5",
"markdown-it": "^14.1.0",
"vue": "^3.4.32"
"vue": "^3.4.27"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@rushstack/eslint-patch": "^1.10.3",
"@rushstack/eslint-patch": "^1.10.2",
"@types/ace": "^0.0.52",
"@types/file-saver": "^2.0.7",
"@types/markdown-it": "^14.1.1",
"@types/markdown-it": "^14.0.1",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@vitejs/plugin-legacy": "^5.4.1",
"@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-legacy": "^5.3.2",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-airbnb-with-typescript": "^8.0.0",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "^2.4.6",
"@vue/test-utils": "^2.4.5",
"autoprefixer": "^10.4.19",
"cypress": "^13.13.1",
"electron": "^31.2.1",
"cypress": "^13.7.3",
"electron": "^29.3.0",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0",
"electron-vite": "^2.3.0",
"electron-vite": "^2.1.0",
"eslint": "8.57.0",
"eslint-plugin-cypress": "^3.3.0",
"eslint-plugin-vue": "^9.27.0",
"eslint-plugin-vuejs-accessibility": "^2.4.0",
"jsdom": "^24.1.0",
"markdownlint-cli": "^0.41.0",
"postcss": "^8.4.39",
"remark-cli": "^12.0.1",
"remark-lint-no-dead-urls": "^2.0.0",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.25.0",
"eslint-plugin-vuejs-accessibility": "^2.2.1",
"jsdom": "^24.0.0",
"markdownlint-cli": "^0.39.0",
"postcss": "^8.4.38",
"remark-cli": "^12.0.0",
"remark-lint-no-dead-urls": "^1.1.0",
"remark-preset-lint-consistent": "^6.0.0",
"remark-validate-links": "^13.0.1",
"sass": "~1.79.4",
"start-server-and-test": "^2.0.4",
"terser": "^5.31.3",
"tslib": "^2.6.3",
"typescript": "~5.5.4",
"vite": "^5.4.8",
"vitest": "^2.0.3",
"vue-tsc": "^2.0.26",
"sass": "^1.75.0",
"start-server-and-test": "^2.0.3",
"terser": "^5.30.3",
"tslib": "^2.6.2",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vitest": "^1.5.0",
"vue-tsc": "^2.0.13",
"yaml-lint": "^1.7.0"
},
"//devDependencies": {

View File

@@ -58,10 +58,6 @@ def add_or_update_settings() -> None:
# Details: # pylint: disable-next=line-too-long
# - https://archive.ph/2024.01.06-003914/https://github.com/microsoft/vscode/issues/179274, https://web.archive.org/web/20240106003915/https://github.com/microsoft/vscode/issues/179274
# Disable telemetry
configure_setting_key('redhat.telemetry.enabled', False)
configure_setting_key('gitlens.telemetry.enabled', False)
def configure_setting_key(configuration_key: str, desired_value: Any) -> None:
try:
with open(VSCODE_SETTINGS_JSON_FILE, 'r+', encoding='utf-8') as file:

View File

@@ -1,51 +0,0 @@
# validate-collections-yaml
This script validates YAML collection files against a predefined schema to ensure their integrity.
## Prerequisites
- Python 3.x installed on your system.
## Running in a Virtual Environment (Recommended)
Using a virtual environment isolates dependencies and prevents conflicts.
1. **Create a virtual environment:**
```bash
python3 -m venv ./scripts/validate-collections-yaml/.venv
```
2. **Activate the virtual environment:**
```bash
source ./scripts/validate-collections-yaml/.venv/bin/activate
```
3. **Install dependencies:**
```bash
python3 -m pip install -r ./scripts/validate-collections-yaml/requirements.txt
```
4. **Run the script:**
```bash
python3 ./scripts/validate-collections-yaml
```
## Running Globally
Running the script globally is less recommended due to potential dependency conflicts.
1. **Install dependencies:**
```bash
python3 -m pip install -r ./scripts/validate-collections-yaml/requirements.txt
```
2. **Run the script:**
```bash
python3 ./scripts/validate-collections-yaml
```

View File

@@ -1,62 +0,0 @@
"""
Description:
This script validates collection YAML files against the expected schema.
Usage:
python3 ./scripts/validate-collections-yaml
Notes:
This script requires the `jsonschema` and `pyyaml` packages (see requirements.txt).
"""
# pylint: disable=missing-function-docstring
from os import path
import sys
from glob import glob
from typing import List
from jsonschema import exceptions, validate # pylint: disable=import-error
import yaml # pylint: disable=import-error
SCHEMA_FILE_PATH = './src/application/collections/.schema.yaml'
COLLECTIONS_GLOB_PATTERN = './src/application/collections/*.yaml'
def main() -> None:
schema_yaml = read_file(SCHEMA_FILE_PATH)
schema_json = convert_yaml_to_json(schema_yaml)
collection_file_paths = find_collection_files(COLLECTIONS_GLOB_PATTERN)
print(f'Found {len(collection_file_paths)} YAML files to validate.')
total_invalid_files = 0
for collection_file_path in collection_file_paths:
file_name = path.basename(collection_file_path)
print(f'Validating {file_name}...')
collection_yaml = read_file(collection_file_path)
collection_json = convert_yaml_to_json(collection_yaml)
try:
validate(instance=collection_json, schema=schema_json)
print(f'Success: {file_name} is valid.')
except exceptions.ValidationError as err:
print(f'Error: Validation failed for {file_name}.', file=sys.stderr)
print(str(err), file=sys.stderr)
total_invalid_files += 1
if total_invalid_files > 0:
print(f'Validation complete with {total_invalid_files} invalid files.', file=sys.stderr)
sys.exit(1)
else:
print('Validation complete. All files are valid.')
sys.exit(0)
def read_file(file_path: str) -> str:
with open(file_path, 'r', encoding='utf-8') as file:
return file.read()
def find_collection_files(glob_pattern: str) -> List[str]:
files = glob(glob_pattern)
filtered_files = [f for f in files if not path.basename(f).startswith('.')]
return filtered_files
def convert_yaml_to_json(yaml_content: str) -> dict:
return yaml.safe_load(yaml_content)
if __name__ == '__main__':
main()

View File

@@ -1,6 +0,0 @@
attrs==23.2.0
jsonschema==4.22.0
jsonschema-specifications==2023.12.1
PyYAML==6.0.1
referencing==0.35.1
rpds-py==0.18.1

View File

@@ -91,7 +91,7 @@ async function verifyFilesExist(directoryPath, filePatterns) {
if (!match) {
die(
`No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``,
`\nFiles in directory:\n${files.map((file) => `- ${file}`).join('\n')}`,
`\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`,
);
}
}

View File

@@ -5,13 +5,13 @@ export type EnumType = number | string;
export type EnumVariable<T extends EnumType, TEnumValue extends EnumType>
= { [key in T]: TEnumValue };
export interface EnumParser<TEnum> {
export interface IEnumParser<TEnum> {
parseEnum(value: string, propertyName: string): TEnum;
}
export function createEnumParser<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>,
): EnumParser<TEnumValue> {
): IEnumParser<TEnumValue> {
return {
parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable),
};
@@ -33,25 +33,23 @@ function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
if (!casedValue) {
throw new Error(`unknown ${enumName}: "${value}"`);
}
return enumVariable[casedValue as keyof EnumVariable<T, TEnumValue>];
return enumVariable[casedValue as keyof typeof enumVariable];
}
export function getEnumNames
<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>,
): (string & keyof EnumVariable<T, TEnumValue>)[] {
): string[] {
return Object
.values(enumVariable)
.filter((
enumMember,
): enumMember is string & (keyof EnumVariable<T, TEnumValue>) => isString(enumMember));
.filter((enumMember): enumMember is string => isString(enumMember));
}
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>,
): TEnumValue[] {
return getEnumNames(enumVariable)
.map((name) => enumVariable[name]) as TEnumValue[];
.map((level) => enumVariable[level]) as TEnumValue[];
}
export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(

View File

@@ -1,25 +0,0 @@
import { isArray } from '@/TypeHelpers';
export type OptionalString = string | undefined | null;
export function filterEmptyStrings(
texts: readonly OptionalString[],
isArrayType: typeof isArray = isArray,
): string[] {
if (!isArrayType(texts)) {
throw new Error(`Invalid input: Expected an array, but received type ${typeof texts}.`);
}
assertArrayItemsAreStringLike(texts);
return texts
.filter((title): title is string => Boolean(title));
}
function assertArrayItemsAreStringLike(
texts: readonly unknown[],
): asserts texts is readonly OptionalString[] {
const invalidItems = texts.filter((item) => !(typeof item === 'string' || item === undefined || item === null));
if (invalidItems.length > 0) {
const invalidTypes = invalidItems.map((item) => typeof item).join(', ');
throw new Error(`Invalid array items: Expected items as string, undefined, or null. Received invalid types: ${invalidTypes}.`);
}
}

View File

@@ -1,29 +0,0 @@
import { isString } from '@/TypeHelpers';
import { splitTextIntoLines } from './SplitTextIntoLines';
export function indentText(
text: string,
indentLevel = 1,
utilities: TextIndentationUtilities = DefaultUtilities,
): string {
if (!utilities.isStringType(text)) {
throw new Error(`Indentation error: The input must be a string. Received type: ${typeof text}.`);
}
if (indentLevel <= 0) {
throw new Error(`Indentation error: The indent level must be a positive integer. Received: ${indentLevel}.`);
}
const indentation = '\t'.repeat(indentLevel);
return utilities.splitIntoLines(text)
.map((line) => (line ? `${indentation}${line}` : line))
.join('\n');
}
interface TextIndentationUtilities {
readonly splitIntoLines: typeof splitTextIntoLines;
readonly isStringType: typeof isString;
}
const DefaultUtilities: TextIndentationUtilities = {
splitIntoLines: splitTextIntoLines,
isStringType: isString,
};

View File

@@ -1,11 +0,0 @@
import { isString } from '@/TypeHelpers';
export function splitTextIntoLines(
text: string,
isStringType = isString,
): string[] {
if (!isStringType(text)) {
throw new Error(`Line splitting error: Expected a string but received type '${typeof text}'.`);
}
return text.split(/\r\n|\r|\n/);
}

View File

@@ -1,6 +1,6 @@
import type { IApplication } from '@/domain/IApplication';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { assertInRange } from '@/application/Common/Enum';
import { CategoryCollectionState } from './State/CategoryCollectionState';
@@ -17,7 +17,7 @@ export class ApplicationContext implements IApplicationContext {
public currentOs: OperatingSystem;
public get state(): ICategoryCollectionState {
return this.getState(this.collection.os);
return this.states[this.collection.os];
}
private readonly states: StateMachine;
@@ -26,51 +26,30 @@ export class ApplicationContext implements IApplicationContext {
public readonly app: IApplication,
initialContext: OperatingSystem,
) {
this.setContext(initialContext);
this.states = initializeStates(app);
this.changeContext(initialContext);
}
public changeContext(os: OperatingSystem): void {
assertInRange(os, OperatingSystem);
if (this.currentOs === os) {
return;
}
const collection = this.app.getCollection(os);
this.collection = collection;
const event: IApplicationContextChangedEvent = {
newState: this.getState(os),
oldState: this.getState(this.currentOs),
newState: this.states[os],
oldState: this.states[this.currentOs],
};
this.setContext(os);
this.contextChanged.notify(event);
}
private setContext(os: OperatingSystem): void {
validateOperatingSystem(os, this.app);
this.collection = this.app.getCollection(os);
this.currentOs = os;
}
private getState(os: OperatingSystem): ICategoryCollectionState {
const state = this.states.get(os);
if (!state) {
throw new Error(`Operating system "${OperatingSystem[os]}" state is unknown.`);
}
return state;
}
}
function validateOperatingSystem(
os: OperatingSystem,
app: IApplication,
): void {
assertInRange(os, OperatingSystem);
if (!app.getSupportedOsList().includes(os)) {
throw new Error(`Operating system "${OperatingSystem[os]}" is not supported.`);
}
}
function initializeStates(app: IApplication): StateMachine {
const machine = new Map<OperatingSystem, ICategoryCollectionState>();
for (const collection of app.collections) {
machine.set(collection.os, new CategoryCollectionState(collection));
machine[collection.os] = new CategoryCollectionState(collection);
}
return machine;
}

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { AdaptiveFilterContext } from './Filter/AdaptiveFilterContext';
import { ApplicationCode } from './Code/ApplicationCode';

View File

@@ -1,20 +1,18 @@
import type { Script } from '@/domain/Executables/Script/Script';
import type { IScript } from '@/domain/IScript';
import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent {
public readonly code: string;
public readonly addedScripts: ReadonlyArray<Script>;
public readonly addedScripts: ReadonlyArray<IScript>;
public readonly removedScripts: ReadonlyArray<Script>;
public readonly removedScripts: ReadonlyArray<IScript>;
public readonly changedScripts: ReadonlyArray<Script>;
public readonly changedScripts: ReadonlyArray<IScript>;
private readonly scripts: Map<Script, ICodePosition>;
private readonly scripts: Map<IScript, ICodePosition>;
constructor(
code: string,
@@ -27,7 +25,7 @@ export class CodeChangedEvent implements ICodeChangedEvent {
this.addedScripts = selectIfNotExists(newScripts, oldScripts);
this.removedScripts = selectIfNotExists(oldScripts, newScripts);
this.changedScripts = getChangedScripts(oldScripts, newScripts);
this.scripts = new Map<Script, ICodePosition>();
this.scripts = new Map<IScript, ICodePosition>();
scripts.forEach((position, selection) => {
this.scripts.set(selection.script, position);
});
@@ -37,13 +35,13 @@ export class CodeChangedEvent implements ICodeChangedEvent {
return this.scripts.size === 0;
}
public getScriptPositionInCode(script: Script): ICodePosition {
return this.getPositionById(script.executableId);
public getScriptPositionInCode(script: IScript): ICodePosition {
return this.getPositionById(script.id);
}
private getPositionById(scriptId: ExecutableId): ICodePosition {
private getPositionById(scriptId: string): ICodePosition {
const position = [...this.scripts.entries()]
.filter(([s]) => s.executableId === scriptId)
.filter(([s]) => s.id === scriptId)
.map(([, pos]) => pos)
.at(0);
if (!position) {
@@ -54,12 +52,12 @@ export class CodeChangedEvent implements ICodeChangedEvent {
}
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = splitTextIntoLines(script).length;
const totalLines = script.split(/\r\n|\r|\n/).length;
const missingPositions = positions.filter((position) => position.endLine > totalLines);
if (missingPositions.length > 0) {
throw new Error(
`Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"`
+ ` (total code lines: ${totalLines}).`,
+ `(total code lines: ${totalLines}).`,
);
}
}
@@ -67,7 +65,7 @@ function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodeP
function getChangedScripts(
oldScripts: ReadonlyArray<SelectedScript>,
newScripts: ReadonlyArray<SelectedScript>,
): ReadonlyArray<Script> {
): ReadonlyArray<IScript> {
return newScripts
.filter((newScript) => oldScripts.find((oldScript) => oldScript.id === newScript.id
&& oldScript.revert !== newScript.revert))

View File

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

View File

@@ -1,4 +1,3 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeBuilder } from './ICodeBuilder';
const TotalFunctionSeparatorChars = 58;
@@ -16,7 +15,7 @@ export abstract class CodeBuilder implements ICodeBuilder {
this.lines.push('');
return this;
}
const lines = splitTextIntoLines(code);
const lines = code.match(/[^\r\n]+/g);
if (lines) {
this.lines.push(...lines);
}

View File

@@ -1,5 +1,5 @@
import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { FilterChange } from './Event/FilterChange';
import { LinearFilterStrategy } from './Strategy/LinearFilterStrategy';
import type { FilterResult } from './Result/FilterResult';

View File

@@ -1,11 +1,11 @@
import type { Script } from '@/domain/Executables/Script/Script';
import type { Category } from '@/domain/Executables/Category/Category';
import type { IScript } from '@/domain/IScript';
import type { ICategory } from '@/domain/ICategory';
import type { FilterResult } from './FilterResult';
export class AppliedFilterResult implements FilterResult {
constructor(
public readonly scriptMatches: ReadonlyArray<Script>,
public readonly categoryMatches: ReadonlyArray<Category>,
public readonly scriptMatches: ReadonlyArray<IScript>,
public readonly categoryMatches: ReadonlyArray<ICategory>,
public readonly query: string,
) {
if (!query) { throw new Error('Query is empty or undefined'); }

View File

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

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import type { FilterResult } from '../Result/FilterResult';
export interface FilterStrategy {

View File

@@ -1,8 +1,7 @@
import type { Category } from '@/domain/Executables/Category/Category';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { Documentable } from '@/domain/Executables/Documentable';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { Script } from '@/domain/Executables/Script/Script';
import type { ICategory, IScript } from '@/domain/ICategory';
import type { IScriptCode } from '@/domain/IScriptCode';
import type { IDocumentable } from '@/domain/IDocumentable';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { AppliedFilterResult } from '../Result/AppliedFilterResult';
import type { FilterStrategy } from './FilterStrategy';
import type { FilterResult } from '../Result/FilterResult';
@@ -25,7 +24,7 @@ export class LinearFilterStrategy implements FilterStrategy {
}
function matchesCategory(
category: Category,
category: ICategory,
filterLowercase: string,
): boolean {
return matchesAny(
@@ -35,7 +34,7 @@ function matchesCategory(
}
function matchesScript(
script: Script,
script: IScript,
filterLowercase: string,
): boolean {
return matchesAny(
@@ -59,7 +58,7 @@ function matchName(
}
function matchCode(
code: ScriptCode,
code: IScriptCode,
filterLowercase: string,
): boolean {
if (code.execute.toLowerCase().includes(filterLowercase)) {
@@ -72,7 +71,7 @@ function matchCode(
}
function matchDocumentation(
documentable: Documentable,
documentable: IDocumentable,
filterLowercase: string,
): boolean {
return documentable.docs.some(

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { IApplicationCode } from './Code/IApplicationCode';
import type { ReadonlyFilterContext, FilterContext } from './Filter/FilterContext';

View File

@@ -1,9 +1,9 @@
import type { Category } from '@/domain/Executables/Category/Category';
import type { ICategory } from '@/domain/ICategory';
import type { CategorySelectionChangeCommand } from './CategorySelectionChange';
export interface ReadonlyCategorySelection {
areAllScriptsSelected(category: Category): boolean;
isAnyScriptSelected(category: Category): boolean;
areAllScriptsSelected(category: ICategory): boolean;
isAnyScriptSelected(category: ICategory): boolean;
}
export interface CategorySelection extends ReadonlyCategorySelection {

View File

@@ -1,5 +1,3 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
type CategorySelectionStatus = {
readonly isSelected: true;
readonly isReverted: boolean;
@@ -8,7 +6,7 @@ type CategorySelectionStatus = {
};
export interface CategorySelectionChange {
readonly categoryId: ExecutableId;
readonly categoryId: number;
readonly newStatus: CategorySelectionStatus;
}

View File

@@ -1,5 +1,5 @@
import type { Category } from '@/domain/Executables/Category/Category';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ICategory } from '@/domain/ICategory';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import type { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange';
import type { CategorySelection } from './CategorySelection';
import type { ScriptSelection } from '../Script/ScriptSelection';
@@ -13,7 +13,7 @@ export class ScriptToCategorySelectionMapper implements CategorySelection {
}
public areAllScriptsSelected(category: Category): boolean {
public areAllScriptsSelected(category: ICategory): boolean {
const { selectedScripts } = this.scriptSelection;
if (selectedScripts.length === 0) {
return false;
@@ -23,11 +23,11 @@ export class ScriptToCategorySelectionMapper implements CategorySelection {
return false;
}
return scripts.every(
(script) => selectedScripts.some((selected) => selected.id === script.executableId),
(script) => selectedScripts.some((selected) => selected.id === script.id),
);
}
public isAnyScriptSelected(category: Category): boolean {
public isAnyScriptSelected(category: ICategory): boolean {
const { selectedScripts } = this.scriptSelection;
if (selectedScripts.length === 0) {
return false;
@@ -50,7 +50,7 @@ export class ScriptToCategorySelectionMapper implements CategorySelection {
const scripts = category.getAllScriptsRecursively();
const scriptsChangesInCategory = scripts
.map((script): ScriptSelectionChange => ({
scriptId: script.executableId,
scriptId: script.id,
newStatus: {
...change.newStatus,
},

View File

@@ -1,10 +1,9 @@
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
import type { Script } from '@/domain/Executables/Script/Script';
import type { IScript } from '@/domain/IScript';
import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { UserSelectedScript } from './UserSelectedScript';
import type { ScriptSelection } from './ScriptSelection';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
@@ -17,7 +16,7 @@ export type DebounceFunction = typeof batchedDebounce<ScriptSelectionChangeComma
export class DebouncedScriptSelection implements ScriptSelection {
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
private readonly scripts: Repository<SelectedScript>;
private readonly scripts: Repository<string, SelectedScript>;
public readonly processChanges: ScriptSelection['processChanges'];
@@ -26,7 +25,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
selectedScripts: ReadonlyArray<SelectedScript>,
debounce: DebounceFunction = batchedDebounce,
) {
this.scripts = new InMemoryRepository<SelectedScript>();
this.scripts = new InMemoryRepository<string, SelectedScript>();
for (const script of selectedScripts) {
this.scripts.addItem(script);
}
@@ -39,8 +38,8 @@ export class DebouncedScriptSelection implements ScriptSelection {
);
}
public isSelected(scriptExecutableId: ExecutableId): boolean {
return this.scripts.exists(scriptExecutableId);
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
public get selectedScripts(): readonly SelectedScript[] {
@@ -50,7 +49,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
public selectAll(): void {
const scriptsToSelect = this.collection
.getAllScripts()
.filter((script) => !this.scripts.exists(script.executableId))
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new UserSelectedScript(script, false));
if (scriptsToSelect.length === 0) {
return;
@@ -81,7 +80,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
});
}
public selectOnly(scripts: readonly Script[]): void {
public selectOnly(scripts: readonly IScript[]): void {
assertNonEmptyScriptSelection(scripts);
this.processChanges({
changes: [
@@ -117,12 +116,12 @@ export class DebouncedScriptSelection implements ScriptSelection {
private applyChange(change: ScriptSelectionChange): number {
const script = this.collection.getScript(change.scriptId);
if (change.newStatus.isSelected) {
return this.addOrUpdateScript(script.executableId, change.newStatus.isReverted);
return this.addOrUpdateScript(script.id, change.newStatus.isReverted);
}
return this.removeScript(script.executableId);
return this.removeScript(script.id);
}
private addOrUpdateScript(scriptId: ExecutableId, revert: boolean): number {
private addOrUpdateScript(scriptId: string, revert: boolean): number {
const script = this.collection.getScript(scriptId);
const selectedScript = new UserSelectedScript(script, revert);
if (!this.scripts.exists(selectedScript.id)) {
@@ -137,7 +136,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
return 1;
}
private removeScript(scriptId: ExecutableId): number {
private removeScript(scriptId: string): number {
if (!this.scripts.exists(scriptId)) {
return 0;
}
@@ -146,31 +145,31 @@ export class DebouncedScriptSelection implements ScriptSelection {
}
}
function assertNonEmptyScriptSelection(selectedItems: readonly Script[]) {
function assertNonEmptyScriptSelection(selectedItems: readonly IScript[]) {
if (selectedItems.length === 0) {
throw new Error('Provided script array is empty. To deselect all scripts, please use the deselectAll() method instead.');
}
}
function getScriptIdsToBeSelected(
existingItems: ReadonlyRepository<SelectedScript>,
desiredScripts: readonly Script[],
existingItems: ReadonlyRepository<string, SelectedScript>,
desiredScripts: readonly IScript[],
): string[] {
return desiredScripts
.filter((script) => !existingItems.exists(script.executableId))
.map((script) => script.executableId);
.filter((script) => !existingItems.exists(script.id))
.map((script) => script.id);
}
function getScriptIdsToBeDeselected(
existingItems: ReadonlyRepository<SelectedScript>,
desiredScripts: readonly Script[],
existingItems: ReadonlyRepository<string, SelectedScript>,
desiredScripts: readonly IScript[],
): string[] {
return existingItems
.getItems()
.filter((existing) => !desiredScripts.some((script) => existing.id === script.executableId))
.filter((existing) => !desiredScripts.some((script) => existing.id === script.id))
.map((script) => script.id);
}
function equals(a: SelectedScript, b: SelectedScript): boolean {
return a.script.executableId === b.script.executableId && a.revert === b.revert;
return a.script.equals(b.script.id) && a.revert === b.revert;
}

View File

@@ -1,17 +1,16 @@
import type { IEventSource } from '@/infrastructure/Events/IEventSource';
import type { Script } from '@/domain/Executables/Script/Script';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { IScript } from '@/domain/IScript';
import type { SelectedScript } from './SelectedScript';
import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
export interface ReadonlyScriptSelection {
readonly changed: IEventSource<readonly SelectedScript[]>;
readonly selectedScripts: readonly SelectedScript[];
isSelected(scriptExecutableId: ExecutableId): boolean;
isSelected(scriptId: string): boolean;
}
export interface ScriptSelection extends ReadonlyScriptSelection {
selectOnly(scripts: readonly Script[]): void;
selectOnly(scripts: readonly IScript[]): void;
selectAll(): void;
deselectAll(): void;
processChanges(action: ScriptSelectionChangeCommand): void;

View File

@@ -1,5 +1,3 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
export type ScriptSelectionStatus = {
readonly isSelected: true;
readonly isReverted: boolean;
@@ -9,7 +7,7 @@ export type ScriptSelectionStatus = {
};
export interface ScriptSelectionChange {
readonly scriptId: ExecutableId;
readonly scriptId: string;
readonly newStatus: ScriptSelectionStatus;
}

View File

@@ -1,7 +1,9 @@
import type { Script } from '@/domain/Executables/Script/Script';
import type { RepositoryEntity } from '@/application/Repository/RepositoryEntity';
import type { IEntity } from '@/infrastructure/Entity/IEntity';
import type { IScript } from '@/domain/IScript';
export interface SelectedScript extends RepositoryEntity {
readonly script: Script;
type ScriptId = IScript['id'];
export interface SelectedScript extends IEntity<ScriptId> {
readonly script: IScript;
readonly revert: boolean;
}

View File

@@ -1,16 +1,17 @@
import type { Script } from '@/domain/Executables/Script/Script';
import type { RepositoryEntity } from '@/application/Repository/RepositoryEntity';
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import type { IScript } from '@/domain/IScript';
import type { SelectedScript } from './SelectedScript';
export class UserSelectedScript implements RepositoryEntity {
public readonly id: string;
type SelectedScriptId = SelectedScript['id'];
export class UserSelectedScript extends BaseEntity<SelectedScriptId> {
constructor(
public readonly script: Script,
public readonly script: IScript,
public readonly revert: boolean,
) {
this.id = script.executableId;
super(script.id);
if (revert && !script.canRevert()) {
throw new Error(`The script with ID '${script.executableId}' is not reversible and cannot be reverted.`);
throw new Error(`The script with ID '${script.id}' is not reversible and cannot be reverted.`);
}
}
}

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper';
import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection';
import type { CategorySelection } from './Category/CategorySelection';

View File

@@ -1,48 +1,40 @@
import type { CollectionData } from '@/application/collections/';
import type { IApplication } from '@/domain/IApplication';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import WindowsData from '@/application/collections/windows.yaml';
import MacOsData from '@/application/collections/macos.yaml';
import LinuxData from '@/application/collections/linux.yaml';
import { parseProjectDetails, type ProjectDetailsParser } from '@/application/Parser/ProjectDetailsParser';
import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser';
import { Application } from '@/domain/Application';
import { parseCategoryCollection, type CategoryCollectionParser } from './CategoryCollectionParser';
import { createTypeValidator, type TypeValidator } from './Common/TypeValidator';
import type { IAppMetadata } from '@/infrastructure/EnvironmentVariables/IAppMetadata';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { parseCategoryCollection } from './CategoryCollectionParser';
export function parseApplication(
collectionsData: readonly CollectionData[] = PreParsedCollections,
utilities: ApplicationParserUtilities = DefaultUtilities,
categoryParser = parseCategoryCollection,
projectDetailsParser = parseProjectDetails,
metadata: IAppMetadata = EnvironmentVariablesFactory.Current.instance,
collectionsData = PreParsedCollections,
): IApplication {
validateCollectionsData(collectionsData, utilities.validator);
const projectDetails = utilities.parseProjectDetails();
validateCollectionsData(collectionsData);
const projectDetails = projectDetailsParser(metadata);
const collections = collectionsData.map(
(collection) => utilities.parseCategoryCollection(collection, projectDetails),
(collection) => categoryParser(collection, projectDetails),
);
const app = new Application(projectDetails, collections);
return app;
}
const PreParsedCollections: readonly CollectionData[] = [
export type CategoryCollectionParserType
= (file: CollectionData, projectDetails: ProjectDetails) => ICategoryCollection;
const PreParsedCollections: readonly CollectionData [] = [
WindowsData, MacOsData, LinuxData,
];
function validateCollectionsData(
collections: readonly CollectionData[],
validator: TypeValidator,
) {
validator.assertNonEmptyCollection({
value: collections,
valueName: 'Collections',
});
function validateCollectionsData(collections: readonly CollectionData[]) {
if (!collections.length) {
throw new Error('missing collections');
}
}
interface ApplicationParserUtilities {
readonly parseCategoryCollection: CategoryCollectionParser;
readonly validator: TypeValidator;
readonly parseProjectDetails: ProjectDetailsParser;
}
const DefaultUtilities: ApplicationParserUtilities = {
parseCategoryCollection,
parseProjectDetails,
validator: createTypeValidator(),
};

View File

@@ -1,75 +1,34 @@
import type { CollectionData } from '@/application/collections/';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { CategoryCollection } from '@/domain/Collection/CategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategoryCollection } from '@/domain/CategoryCollection';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { createEnumParser, type EnumParser } from '../Common/Enum';
import { parseCategory, type CategoryParser } from './Executable/CategoryParser';
import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
import { createTypeValidator, type TypeValidator } from './Common/TypeValidator';
import { createCategoryCollectionContext, type CategoryCollectionContextFactory } from './Executable/CategoryCollectionContext';
import { createEnumParser } from '../Common/Enum';
import { parseCategory } from './CategoryParser';
import { CategoryCollectionParseContext } from './Script/CategoryCollectionParseContext';
import { ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
export const parseCategoryCollection: CategoryCollectionParser = (
content,
projectDetails,
utilities: CategoryCollectionParserUtilities = DefaultUtilities,
) => {
validateCollection(content, utilities.validator);
const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails);
const collectionContext = utilities.createContext(content.functions, scripting.language);
const categories = content.actions.map(
(action) => utilities.parseCategory(action, collectionContext),
);
const os = utilities.osParser.parseEnum(content.os, 'os');
const collection = utilities.createCategoryCollection({
os, actions: categories, scripting,
});
return collection;
};
export type CategoryCollectionFactory = (
...parameters: ConstructorParameters<typeof CategoryCollection>
) => ICategoryCollection;
export interface CategoryCollectionParser {
(
content: CollectionData,
projectDetails: ProjectDetails,
utilities?: CategoryCollectionParserUtilities,
): ICategoryCollection;
}
function validateCollection(
export function parseCategoryCollection(
content: CollectionData,
validator: TypeValidator,
): void {
validator.assertObject({
value: content,
valueName: 'Collection',
allowedProperties: [
'os', 'scripting', 'actions', 'functions',
],
});
validator.assertNonEmptyCollection({
value: content.actions,
valueName: '\'actions\' in collection',
});
projectDetails: ProjectDetails,
osParser = createEnumParser(OperatingSystem),
): ICategoryCollection {
validate(content);
const scripting = new ScriptingDefinitionParser()
.parse(content.scripting, projectDetails);
const context = new CategoryCollectionParseContext(content.functions, scripting);
const categories = content.actions.map((action) => parseCategory(action, context));
const os = osParser.parseEnum(content.os, 'os');
const collection = new CategoryCollection(
os,
categories,
scripting,
);
return collection;
}
interface CategoryCollectionParserUtilities {
readonly osParser: EnumParser<OperatingSystem>;
readonly validator: TypeValidator;
readonly parseScriptingDefinition: ScriptingDefinitionParser;
readonly createContext: CategoryCollectionContextFactory;
readonly parseCategory: CategoryParser;
readonly createCategoryCollection: CategoryCollectionFactory;
function validate(content: CollectionData): void {
if (!content.actions.length) {
throw new Error('content does not define any action');
}
}
const DefaultUtilities: CategoryCollectionParserUtilities = {
osParser: createEnumParser(OperatingSystem),
validator: createTypeValidator(),
parseScriptingDefinition,
createContext: createCategoryCollectionContext,
parseCategory,
createCategoryCollection: (...args) => new CategoryCollection(...args),
};

View File

@@ -0,0 +1,171 @@
import type {
CategoryData, ScriptData, CategoryOrScriptData,
} from '@/application/collections/';
import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import type { ICategory } from '@/domain/ICategory';
import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from './NodeValidation/NodeDataValidator';
import { NodeDataType } from './NodeValidation/NodeDataType';
import type { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
let categoryIdCounter = 0;
export function parseCategory(
category: CategoryData,
context: ICategoryCollectionParseContext,
utilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
): Category {
return parseCategoryRecursively({
categoryData: category,
context,
utilities,
});
}
interface CategoryParseContext {
readonly categoryData: CategoryData;
readonly context: ICategoryCollectionParseContext;
readonly parentCategory?: CategoryData;
readonly utilities: CategoryParserUtilities;
}
function parseCategoryRecursively(
context: CategoryParseContext,
): Category | never {
const validator = ensureValidCategory(context);
const children: CategoryChildren = {
subcategories: new Array<Category>(),
subscripts: new Array<Script>(),
};
for (const data of context.categoryData.children) {
parseNode({
nodeData: data,
children,
parent: context.categoryData,
utilities: context.utilities,
context: context.context,
});
}
try {
return context.utilities.createCategory({
id: categoryIdCounter++,
name: context.categoryData.category,
docs: context.utilities.parseDocs(context.categoryData),
subcategories: children.subcategories,
scripts: children.subscripts,
});
} catch (error) {
throw context.utilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse category.'),
);
}
}
function ensureValidCategory(
context: CategoryParseContext,
): NodeDataValidator {
const category = context.categoryData;
const validator: NodeDataValidator = context.utilities.createValidator({
type: NodeDataType.Category,
selfNode: context.categoryData,
parentNode: context.parentCategory,
});
validator.assertDefined(category);
validator.assertValidName(category.category);
validator.assert(
() => Boolean(category.children) && category.children.length > 0,
`"${category.category}" has no children.`,
);
return validator;
}
interface CategoryChildren {
readonly subcategories: Category[];
readonly subscripts: Script[];
}
interface NodeParseContext {
readonly nodeData: CategoryOrScriptData;
readonly children: CategoryChildren;
readonly parent: CategoryData;
readonly context: ICategoryCollectionParseContext;
readonly utilities: CategoryParserUtilities;
}
function parseNode(context: NodeParseContext) {
const validator: NodeDataValidator = context.utilities.createValidator({
selfNode: context.nodeData,
parentNode: context.parent,
});
validator.assertDefined(context.nodeData);
validator.assert(
() => isCategory(context.nodeData) || isScript(context.nodeData),
'Node is neither a category or a script.',
);
if (isCategory(context.nodeData)) {
const subCategory = parseCategoryRecursively({
categoryData: context.nodeData,
context: context.context,
parentCategory: context.parent,
utilities: context.utilities,
});
context.children.subcategories.push(subCategory);
} else { // A script
const script = context.utilities.parseScript(context.nodeData, context.context);
context.children.subscripts.push(script);
}
}
function isScript(data: CategoryOrScriptData): data is ScriptData {
return hasCode(data) || hasCall(data);
}
function isCategory(data: CategoryOrScriptData): data is CategoryData {
return hasProperty(data, 'category');
}
function hasCode(data: unknown): boolean {
return hasProperty(data, 'code');
}
function hasCall(data: unknown) {
return hasProperty(data, 'call');
}
function hasProperty(
object: unknown,
propertyName: string,
): object is NonNullable<object> {
if (typeof object !== 'object') {
return false;
}
if (object === null) { // `typeof object` is `null`
return false;
}
return Object.prototype.hasOwnProperty.call(object, propertyName);
}
export type CategoryFactory = (
...parameters: ConstructorParameters<typeof Category>
) => ICategory;
interface CategoryParserUtilities {
readonly createCategory: CategoryFactory;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly parseScript: ScriptParser;
readonly parseDocs: DocsParser;
}
const DefaultCategoryParserUtilities: CategoryParserUtilities = {
createCategory: (...parameters) => new Category(...parameters),
wrapError: wrapErrorWithAdditionalContext,
createValidator: createNodeDataValidator,
parseScript,
parseDocs,
};

View File

@@ -1,116 +0,0 @@
import { CustomError } from '@/application/Common/CustomError';
import { indentText } from '@/application/Common/Text/IndentText';
export interface ErrorWithContextWrapper {
(
innerError: Error,
additionalContext: string,
): Error;
}
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
innerError,
additionalContext,
) => {
if (!additionalContext) {
throw new Error('Missing additional context');
}
return new ContextualError({
innerError,
additionalContext,
});
};
/**
* Class for building a detailed error trace.
*
* Alternatives considered:
* - `AggregateError`:
* Similar but not well-serialized or displayed by browsers such as Chromium (last tested v126).
* - `cause` property:
* Not displayed by all browsers (last tested v126).
* Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
*
* This is immutable where the constructor sets the values because using getter functions such as
* `get cause()`, `get message()` does not work on Chromium (last tested v126), but works fine on
* Firefox (last tested v127).
*/
class ContextualError extends CustomError {
constructor(public readonly context: ErrorContext) {
super(
generateDetailedErrorMessageWithContext(context),
{
cause: context.innerError,
},
);
}
}
interface ErrorContext {
readonly innerError: Error;
readonly additionalContext: string;
}
function generateDetailedErrorMessageWithContext(
context: ErrorContext,
): string {
return [
'\n',
// Display the current error message first, then the root cause.
// This prevents repetitive main messages for errors with a `cause:` chain,
// aligning with browser error display conventions.
context.additionalContext,
'\n',
'Error Trace (starting from root cause):',
indentText(
formatErrorTrace(
// Displaying contexts from the top frame (deepest, most recent) aligns with
// common debugger/compiler standard.
extractErrorTraceAscendingFromDeepest(context),
),
),
'\n',
].join('\n');
}
function extractErrorTraceAscendingFromDeepest(
context: ErrorContext,
): string[] {
const originalError = findRootError(context.innerError);
const contextsDescendingFromMostRecent: string[] = [
context.additionalContext,
...gatherContextsFromErrorChain(context.innerError),
originalError.toString(),
];
const contextsAscendingFromDeepest = contextsDescendingFromMostRecent.reverse();
return contextsAscendingFromDeepest;
}
function findRootError(error: Error): Error {
if (error instanceof ContextualError) {
return findRootError(error.context.innerError);
}
return error;
}
function gatherContextsFromErrorChain(
error: Error,
accumulatedContexts: string[] = [],
): string[] {
if (error instanceof ContextualError) {
accumulatedContexts.push(error.context.additionalContext);
return gatherContextsFromErrorChain(error.context.innerError, accumulatedContexts);
}
return accumulatedContexts;
}
function formatErrorTrace(
errorMessages: readonly string[],
): string {
if (errorMessages.length === 1) {
return errorMessages[0];
}
return errorMessages
.map((context, index) => `${index + 1}.${indentText(context)}`)
.join('\n');
}

View File

@@ -1,131 +0,0 @@
import type { PropertyKeys } from '@/TypeHelpers';
import {
isNullOrUndefined, isArray, isPlainObject, isString,
} from '@/TypeHelpers';
export interface TypeValidator {
assertObject<T>(assertion: ObjectAssertion<T>): void;
assertNonEmptyCollection(assertion: NonEmptyCollectionAssertion): void;
assertNonEmptyString(assertion: NonEmptyStringAssertion): void;
}
export interface NonEmptyCollectionAssertion {
readonly value: unknown;
readonly valueName: string;
}
export interface RegexValidationRule {
readonly expectedMatch: RegExp;
readonly errorMessage: string;
}
export interface NonEmptyStringAssertion {
readonly value: unknown;
readonly valueName: string;
readonly rule?: RegexValidationRule;
}
export interface ObjectAssertion<T> {
readonly value: T | unknown;
readonly valueName: string;
readonly allowedProperties?: readonly PropertyKeys<T>[];
}
export function createTypeValidator(): TypeValidator {
return {
assertObject: (assertion) => {
assertDefined(assertion.value, assertion.valueName);
assertPlainObject(assertion.value, assertion.valueName);
assertNoEmptyProperties(assertion.value, assertion.valueName);
if (assertion.allowedProperties !== undefined) {
const allowedProperties = assertion.allowedProperties.map((p) => p as string);
assertAllowedProperties(assertion.value, assertion.valueName, allowedProperties);
}
},
assertNonEmptyCollection: (assertion) => {
assertDefined(assertion.value, assertion.valueName);
assertArray(assertion.value, assertion.valueName);
assertNonEmpty(assertion.value, assertion.valueName);
},
assertNonEmptyString: (assertion) => {
assertDefined(assertion.value, assertion.valueName);
assertString(assertion.value, assertion.valueName);
if (assertion.value.length === 0) {
throw new Error(`'${assertion.valueName}' is missing.`);
}
if (assertion.rule) {
if (!assertion.value.match(assertion.rule.expectedMatch)) {
throw new Error(assertion.rule.errorMessage);
}
}
},
};
}
function assertDefined<T>(
value: T,
valueName: string,
): asserts value is NonNullable<T> {
if (isNullOrUndefined(value)) {
throw new Error(`'${valueName}' is missing.`);
}
}
function assertPlainObject(
value: unknown,
valueName: string,
): asserts value is object {
if (!isPlainObject(value)) {
throw new Error(`'${valueName}' is not an object.`);
}
}
function assertNoEmptyProperties(
value: object,
valueName: string,
): void {
if (Object.keys(value).length === 0) {
throw new Error(`'${valueName}' is an empty object without properties.`);
}
}
function assertAllowedProperties(
value: object,
valueName: string,
allowedProperties: readonly string[],
): void {
const properties = Object.keys(value).map((p) => p as string);
const disallowedProperties = properties.filter(
(prop) => !allowedProperties.map((p) => p as string).includes(prop),
);
if (disallowedProperties.length > 0) {
throw new Error(`'${valueName}' has disallowed properties: ${disallowedProperties.join(', ')}.`);
}
}
function assertArray(
value: unknown,
valueName: string,
): asserts value is Array<unknown> {
if (!isArray(value)) {
throw new Error(`${valueName} should be of type 'array', but is of type '${typeof value}'.`);
}
}
function assertString(
value: unknown,
valueName: string,
): asserts value is string {
if (!isString(value)) {
throw new Error(`${valueName} should be of type 'string', but is of type '${typeof value}'.`);
}
}
function assertNonEmpty(
value: Array<unknown>,
valueName: string,
): void {
if (value.length === 0) {
throw new Error(`'${valueName}' cannot be an empty array.`);
}
}

View File

@@ -0,0 +1,42 @@
import { CustomError } from '@/application/Common/CustomError';
export interface ErrorWithContextWrapper {
(
error: Error,
additionalContext: string,
): Error;
}
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
error: Error,
additionalContext: string,
) => {
return (error instanceof ContextualError ? error : new ContextualError(error))
.withAdditionalContext(additionalContext);
};
/* AggregateError is similar but isn't well-serialized or displayed by browsers */
class ContextualError extends CustomError {
private readonly additionalContext = new Array<string>();
constructor(
public readonly innerError: Error,
) {
super();
}
public withAdditionalContext(additionalContext: string): this {
this.additionalContext.push(additionalContext);
return this;
}
public get message(): string { // toString() is not used when Chromium logs it on console
return [
'\n',
this.innerError.message,
'\n',
'Additional context:',
...this.additionalContext.map((context, index) => `${index + 1}: ${context}`),
].join('\n');
}
}

View File

@@ -50,5 +50,5 @@ class DocumentationContainer {
}
function throwInvalidType(): never {
throw new Error('docs field (documentation) must be a single string or an array of strings.');
throw new Error('docs field (documentation) must be an array of strings');
}

View File

@@ -1,33 +0,0 @@
import type { FunctionData } from '@/application/collections/';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createScriptCompiler, type ScriptCompilerFactory } from './Script/Compiler/ScriptCompilerFactory';
import type { ScriptCompiler } from './Script/Compiler/ScriptCompiler';
export interface CategoryCollectionContext {
readonly compiler: ScriptCompiler;
readonly language: ScriptingLanguage;
}
export interface CategoryCollectionContextFactory {
(
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory?: ScriptCompilerFactory,
): CategoryCollectionContext;
}
export const createCategoryCollectionContext: CategoryCollectionContextFactory = (
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory: ScriptCompilerFactory = createScriptCompiler,
) => {
return {
compiler: compilerFactory({
categoryContext: {
functions: functionsData ?? [],
language,
},
}),
language,
};
};

View File

@@ -1,181 +0,0 @@
import type {
CategoryData, ScriptData, ExecutableData,
} from '@/application/collections/';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script';
import { createCategory, type CategoryFactory } from '@/domain/Executables/Category/CategoryFactory';
import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator';
import { ExecutableType } from './Validation/ExecutableType';
import type { CategoryCollectionContext } from './CategoryCollectionContext';
export const parseCategory: CategoryParser = (
category: CategoryData,
collectionContext: CategoryCollectionContext,
categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
) => {
return parseCategoryRecursively({
categoryData: category,
collectionContext,
categoryUtilities,
});
};
export interface CategoryParser {
(
category: CategoryData,
collectionContext: CategoryCollectionContext,
categoryUtilities?: CategoryParserUtilities,
): Category;
}
interface CategoryParseContext {
readonly categoryData: CategoryData;
readonly collectionContext: CategoryCollectionContext;
readonly parentCategory?: CategoryData;
readonly categoryUtilities: CategoryParserUtilities;
}
function parseCategoryRecursively(
context: CategoryParseContext,
): Category | never {
const validator = ensureValidCategory(context);
const children: CategoryChildren = {
subcategories: new Array<Category>(),
subscripts: new Array<Script>(),
};
for (const data of context.categoryData.children) {
parseUnknownExecutable({
data,
children,
parent: context.categoryData,
categoryUtilities: context.categoryUtilities,
collectionContext: context.collectionContext,
});
}
try {
return context.categoryUtilities.createCategory({
executableId: context.categoryData.category, // Pseudo-ID for uniqueness until real ID support
name: context.categoryData.category,
docs: context.categoryUtilities.parseDocs(context.categoryData),
subcategories: children.subcategories,
scripts: children.subscripts,
});
} catch (error) {
throw context.categoryUtilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse category.'),
);
}
}
function ensureValidCategory(
context: CategoryParseContext,
): ExecutableValidator {
const category = context.categoryData;
const validator: ExecutableValidator = context.categoryUtilities.createValidator({
type: ExecutableType.Category,
self: context.categoryData,
parentCategory: context.parentCategory,
});
validator.assertType((v) => v.assertObject({
value: category,
valueName: category.category ? `Category '${category.category}'` : 'Category',
allowedProperties: [
'docs', 'children', 'category',
],
}));
validator.assertValidName(category.category);
validator.assertType((v) => v.assertNonEmptyCollection({
value: category.children,
valueName: category.category,
}));
return validator;
}
interface CategoryChildren {
readonly subcategories: Category[];
readonly subscripts: Script[];
}
interface ExecutableParseContext {
readonly data: ExecutableData;
readonly children: CategoryChildren;
readonly parent: CategoryData;
readonly collectionContext: CategoryCollectionContext;
readonly categoryUtilities: CategoryParserUtilities;
}
function parseUnknownExecutable(context: ExecutableParseContext) {
const validator: ExecutableValidator = context.categoryUtilities.createValidator({
self: context.data,
parentCategory: context.parent,
});
validator.assertType((v) => v.assertObject({
value: context.data,
valueName: 'Executable',
}));
validator.assert(
() => isCategory(context.data) || isScript(context.data),
'Executable is neither a category or a script.',
);
if (isCategory(context.data)) {
const subCategory = parseCategoryRecursively({
categoryData: context.data,
collectionContext: context.collectionContext,
parentCategory: context.parent,
categoryUtilities: context.categoryUtilities,
});
context.children.subcategories.push(subCategory);
} else { // A script
const script = context.categoryUtilities.parseScript(context.data, context.collectionContext);
context.children.subscripts.push(script);
}
}
function isScript(data: ExecutableData): data is ScriptData {
return hasCode(data) || hasCall(data);
}
function isCategory(data: ExecutableData): data is CategoryData {
return hasProperty(data, 'category');
}
function hasCode(data: unknown): boolean {
return hasProperty(data, 'code');
}
function hasCall(data: unknown) {
return hasProperty(data, 'call');
}
function hasProperty(
object: unknown,
propertyName: string,
): object is NonNullable<object> {
if (typeof object !== 'object') {
return false;
}
if (object === null) { // `typeof object` is `null`
return false;
}
return Object.prototype.hasOwnProperty.call(object, propertyName);
}
interface CategoryParserUtilities {
readonly createCategory: CategoryFactory;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: ExecutableValidatorFactory;
readonly parseScript: ScriptParser;
readonly parseDocs: DocsParser;
}
const DefaultCategoryParserUtilities: CategoryParserUtilities = {
createCategory,
wrapError: wrapErrorWithAdditionalContext,
createValidator: createExecutableDataValidator,
parseScript,
parseDocs,
};

View File

@@ -1,41 +0,0 @@
import { createTypeValidator, type TypeValidator } from '@/application/Parser/Common/TypeValidator';
import { validateParameterName, type ParameterNameValidator } from '@/application/Parser/Executable/Script/Compiler/Function/Shared/ParameterNameValidator';
export interface FunctionCallArgument {
readonly parameterName: string;
readonly argumentValue: string;
}
export interface FunctionCallArgumentFactory {
(
parameterName: string,
argumentValue: string,
utilities?: FunctionCallArgumentFactoryUtilities,
): FunctionCallArgument;
}
export const createFunctionCallArgument: FunctionCallArgumentFactory = (
parameterName: string,
argumentValue: string,
utilities: FunctionCallArgumentFactoryUtilities = DefaultUtilities,
): FunctionCallArgument => {
utilities.validateParameterName(parameterName);
utilities.typeValidator.assertNonEmptyString({
value: argumentValue,
valueName: `Function parameter '${parameterName}'`,
});
return {
parameterName,
argumentValue,
};
};
interface FunctionCallArgumentFactoryUtilities {
readonly typeValidator: TypeValidator;
readonly validateParameterName: ParameterNameValidator;
}
const DefaultUtilities: FunctionCallArgumentFactoryUtilities = {
typeValidator: createTypeValidator(),
validateParameterName,
};

View File

@@ -1,80 +0,0 @@
import type {
FunctionCallData,
FunctionCallsData,
FunctionCallParametersData,
} from '@/application/collections/';
import { isArray, isPlainObject } from '@/TypeHelpers';
import { createTypeValidator, type TypeValidator } from '@/application/Parser/Common/TypeValidator';
import { FunctionCallArgumentCollection } from './Argument/FunctionCallArgumentCollection';
import { ParsedFunctionCall } from './ParsedFunctionCall';
import { createFunctionCallArgument, type FunctionCallArgumentFactory } from './Argument/FunctionCallArgument';
import type { FunctionCall } from './FunctionCall';
export interface FunctionCallsParser {
(
calls: FunctionCallsData,
utilities?: FunctionCallParsingUtilities,
): FunctionCall[];
}
interface FunctionCallParsingUtilities {
readonly typeValidator: TypeValidator;
readonly createCallArgument: FunctionCallArgumentFactory;
}
const DefaultUtilities: FunctionCallParsingUtilities = {
typeValidator: createTypeValidator(),
createCallArgument: createFunctionCallArgument,
};
export const parseFunctionCalls: FunctionCallsParser = (
calls,
utilities = DefaultUtilities,
) => {
const sequence = getCallSequence(calls, utilities.typeValidator);
return sequence.map((call) => parseFunctionCall(call, utilities));
};
function getCallSequence(calls: FunctionCallsData, validator: TypeValidator): FunctionCallData[] {
if (!isPlainObject(calls) && !isArray(calls)) {
throw new Error('called function(s) must be an object or array');
}
if (isArray(calls)) {
validator.assertNonEmptyCollection({
value: calls,
valueName: 'Function call sequence',
});
return calls as FunctionCallData[];
}
const singleCall = calls as FunctionCallData;
return [singleCall];
}
function parseFunctionCall(
call: FunctionCallData,
utilities: FunctionCallParsingUtilities,
): FunctionCall {
utilities.typeValidator.assertObject({
value: call,
valueName: 'Function call',
allowedProperties: ['function', 'parameters'],
});
const callArgs = parseArgs(call.parameters, utilities.createCallArgument);
return new ParsedFunctionCall(call.function, callArgs);
}
function parseArgs(
parameters: FunctionCallParametersData | undefined,
createArgument: FunctionCallArgumentFactory,
): FunctionCallArgumentCollection {
const parametersMap = parameters ?? {};
return Object.keys(parametersMap)
.map((parameterName) => {
const argumentValue = parametersMap[parameterName];
return createArgument(parameterName, argumentValue);
})
.reduce((args, arg) => {
args.addArgument(arg);
return args;
}, new FunctionCallArgumentCollection());
}

View File

@@ -1,21 +0,0 @@
import type { ParameterDefinitionData } from '@/application/collections/';
import { validateParameterName, type ParameterNameValidator } from '../Shared/ParameterNameValidator';
import type { FunctionParameter } from './FunctionParameter';
export interface FunctionParameterParser {
(
data: ParameterDefinitionData,
validator?: ParameterNameValidator,
): FunctionParameter;
}
export const parseFunctionParameter: FunctionParameterParser = (
data,
validator = validateParameterName,
) => {
validator(data.name);
return {
name: data.name,
isOptional: data.optional || false,
};
};

View File

@@ -1,9 +0,0 @@
import type { FunctionParameter } from './FunctionParameter';
export interface IReadOnlyFunctionParameterCollection {
readonly all: readonly FunctionParameter[];
}
export interface IFunctionParameterCollection extends IReadOnlyFunctionParameterCollection {
addParameter(parameter: FunctionParameter): void;
}

View File

@@ -1,22 +0,0 @@
import { createTypeValidator, type TypeValidator } from '@/application/Parser/Common/TypeValidator';
export interface ParameterNameValidator {
(
parameterName: string,
typeValidator?: TypeValidator,
): void;
}
export const validateParameterName = (
parameterName: string,
typeValidator = createTypeValidator(),
) => {
typeValidator.assertNonEmptyString({
value: parameterName,
valueName: 'Parameter name',
rule: {
expectedMatch: /^[0-9a-zA-Z]+$/,
errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`,
},
});
};

View File

@@ -1,7 +0,0 @@
import type { ScriptData } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
export interface ScriptCompiler {
canCompile(script: ScriptData): boolean;
compile(script: ScriptData): ScriptCode;
}

View File

@@ -1,119 +0,0 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
import type { ScriptCompiler } from './ScriptCompiler';
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
export interface ScriptCompilerInitParameters {
readonly categoryContext: CategoryCollectionDataContext;
readonly utilities?: ScriptCompilerUtilities;
}
export interface ScriptCompilerFactory {
(parameters: ScriptCompilerInitParameters): ScriptCompiler;
}
export const createScriptCompiler: ScriptCompilerFactory = (
parameters,
) => {
return new FunctionCallScriptCompiler(
parameters.categoryContext,
parameters.utilities ?? DefaultUtilities,
);
};
interface ScriptCompilerUtilities {
readonly sharedFunctionsParser: SharedFunctionsParser;
readonly callCompiler: FunctionCallCompiler;
readonly codeValidator: CodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly scriptCodeFactory: ScriptCodeFactory;
}
const DefaultUtilities: ScriptCompilerUtilities = {
sharedFunctionsParser: parseSharedFunctions,
callCompiler: FunctionCallSequenceCompiler.instance,
codeValidator: validateCode,
wrapError: wrapErrorWithAdditionalContext,
scriptCodeFactory: createScriptCode,
};
interface CategoryCollectionDataContext {
readonly functions: readonly FunctionData[];
readonly language: ScriptingLanguage;
}
class FunctionCallScriptCompiler implements ScriptCompiler {
private readonly functions: ISharedFunctionCollection;
private readonly language: ScriptingLanguage;
constructor(
categoryContext: CategoryCollectionDataContext,
private readonly utilities: ScriptCompilerUtilities = DefaultUtilities,
) {
this.functions = this.utilities.sharedFunctionsParser(
categoryContext.functions,
categoryContext.language,
);
this.language = categoryContext.language;
}
public canCompile(script: ScriptData): boolean {
return hasCall(script);
}
public compile(script: ScriptData): ScriptCode {
try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call);
const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(
compiledCode,
this.language,
this.utilities.codeValidator,
);
return this.utilities.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}
function validateCompiledCode(
compiledCode: CompiledCode,
language: ScriptingLanguage,
validate: CodeValidator,
): void {
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
.forEach(
(code) => validate(
code,
language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoTooLongLines,
// Allow duplicated lines to enable calling same function multiple times
],
),
);
}
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
return (data as CallInstruction).call !== undefined;
}

View File

@@ -1,147 +0,0 @@
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import type { Script } from '@/domain/Executables/Script/Script';
import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
import type { CategoryCollectionContext } from '../CategoryCollectionContext';
export interface ScriptParser {
(
data: ScriptData,
collectionContext: CategoryCollectionContext,
scriptUtilities?: ScriptParserUtilities,
): Script;
}
export const parseScript: ScriptParser = (
data,
collectionContext,
scriptUtilities = DefaultUtilities,
) => {
const validator = scriptUtilities.createValidator({
type: ExecutableType.Script,
self: data,
});
validateScript(data, validator);
try {
const script = scriptUtilities.createScript({
executableId: data.name, // Pseudo-ID for uniqueness until real ID support
name: data.name,
code: parseCode(
data,
collectionContext,
scriptUtilities.codeValidator,
scriptUtilities.createCode,
),
docs: scriptUtilities.parseDocs(data),
level: parseLevel(data.recommend, scriptUtilities.levelParser),
});
return script;
} catch (error) {
throw scriptUtilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse script.'),
);
}
};
function parseLevel(
level: string | undefined,
parser: EnumParser<RecommendationLevel>,
): RecommendationLevel | undefined {
if (!level) {
return undefined;
}
return parser.parseEnum(level, 'level');
}
function parseCode(
script: ScriptData,
collectionContext: CategoryCollectionContext,
codeValidator: CodeValidator,
createCode: ScriptCodeFactory,
): ScriptCode {
if (collectionContext.compiler.canCompile(script)) {
return collectionContext.compiler.compile(script);
}
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
const code = createCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, collectionContext.language);
return code;
}
function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode,
validate: CodeValidator,
language: ScriptingLanguage,
) {
filterEmptyStrings([scriptCode.execute, scriptCode.revert])
.forEach(
(code) => validate(
code,
language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
CodeValidationRule.NoTooLongLines,
],
),
);
}
function validateScript(
script: ScriptData,
validator: ExecutableValidator,
): asserts script is NonNullable<ScriptData> {
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
value: script,
valueName: script.name ? `Script '${script.name}'` : 'Script',
allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
],
}));
validator.assertValidName(script.name);
validator.assert(
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call),
'Neither "call" or "code" is defined.',
);
validator.assert(
() => !((script as CodeScriptData).code && (script as CallScriptData).call),
'Both "call" and "code" are defined.',
);
validator.assert(
() => !((script as CodeScriptData).revertCode && (script as CallScriptData).call),
'Both "call" and "revertCode" are defined.',
);
}
interface ScriptParserUtilities {
readonly levelParser: EnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory;
readonly codeValidator: CodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: ExecutableValidatorFactory;
readonly createCode: ScriptCodeFactory;
readonly parseDocs: DocsParser;
}
const DefaultUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel),
createScript,
codeValidator: validateCode,
wrapError: wrapErrorWithAdditionalContext,
createValidator: createExecutableDataValidator,
createCode: createScriptCode,
parseDocs,
};

View File

@@ -1,63 +0,0 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createSyntax, type SyntaxFactory } from './Syntax/SyntaxFactory';
import type { CodeLine, CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export type DuplicateLinesAnalyzer = CodeValidationAnalyzer & {
(
...args: [
...Parameters<CodeValidationAnalyzer>,
syntaxFactory?: SyntaxFactory,
]
): ReturnType<CodeValidationAnalyzer>;
};
export const analyzeDuplicateLines: DuplicateLinesAnalyzer = (
lines: readonly CodeLine[],
language: ScriptingLanguage,
syntaxFactory: SyntaxFactory = createSyntax,
) => {
const syntax = syntaxFactory(language);
return lines
.map((line): CodeLineWithDuplicateOccurrences => ({
lineNumber: line.lineNumber,
shouldBeIgnoredInAnalysis: shouldIgnoreLine(line.text, syntax),
duplicateLineNumbers: lines
.filter((other) => other.text === line.text)
.map((duplicatedLine) => duplicatedLine.lineNumber),
}))
.filter((line) => isNonIgnorableDuplicateLine(line))
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: `Line is duplicated at line numbers ${line.duplicateLineNumbers.join(',')}.`,
}));
};
interface CodeLineWithDuplicateOccurrences {
readonly lineNumber: number;
readonly duplicateLineNumbers: readonly number[];
readonly shouldBeIgnoredInAnalysis: boolean;
}
function isNonIgnorableDuplicateLine(line: CodeLineWithDuplicateOccurrences): boolean {
return !line.shouldBeIgnoredInAnalysis && line.duplicateLineNumbers.length > 1;
}
function shouldIgnoreLine(codeLine: string, syntax: LanguageSyntax): boolean {
return isCommentLine(codeLine, syntax)
|| isLineComposedEntirelyOfCommonCodeParts(codeLine, syntax);
}
function isCommentLine(codeLine: string, syntax: LanguageSyntax): boolean {
return syntax.commentDelimiters.some(
(delimiter) => codeLine.startsWith(delimiter),
);
}
function isLineComposedEntirelyOfCommonCodeParts(
codeLine: string,
syntax: LanguageSyntax,
): boolean {
const codeLineParts = codeLine.toLowerCase().trim().split(' ');
return codeLineParts.every((part) => syntax.commonCodeParts.includes(part));
}

View File

@@ -1,24 +0,0 @@
import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export const analyzeEmptyLines: CodeValidationAnalyzer = (
lines,
) => {
return lines
.filter((line) => isEmptyLine(line.text))
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: (() => {
if (!line.text) {
return 'Empty line';
}
const markedText = line.text
.replaceAll(' ', '{whitespace}')
.replaceAll('\t', '{tab}');
return `Empty line: "${markedText}"`;
})(),
}));
};
function isEmptyLine(line: string): boolean {
return line.trim().length === 0;
}

View File

@@ -1,44 +0,0 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export const analyzeTooLongLines: CodeValidationAnalyzer = (
lines,
language,
) => {
const maxLineLength = getMaxAllowedLineLength(language);
return lines
.filter((line) => line.text.length > maxLineLength)
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: [
`Line is too long (${line.text.length}).`,
`It exceed maximum allowed length ${maxLineLength} by ${line.text.length - maxLineLength} characters.`,
'This may cause bugs due to unintended trimming by operating system, shells or terminal emulators.',
].join(' '),
}));
};
function getMaxAllowedLineLength(language: ScriptingLanguage): number {
switch (language) {
case ScriptingLanguage.batchfile:
/*
The maximum length of the string that you can use at the command prompt is 8191 characters.
https://web.archive.org/web/20240815120224/https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/command-line-string-limitation
*/
return 8191;
case ScriptingLanguage.shellscript:
/*
Tests show:
| OS | Command | Value |
| --- | ------- | ----- |
| Pop!_OS 22.04 | xargs --show-limits | 2088784 |
| macOS Sonoma 14.3 on Intel | getconf ARG_MAX | 1048576 |
| macOS Sonoma 14.3 on Apple Silicon M1 | getconf ARG_MAX | 1048576 |
| Android 12 (4.14.180) with Termux | xargs --show-limits | 2087244 |
*/
return 1048576; // Minimum value for reliability
default:
throw new Error(`Unsupported language: ${ScriptingLanguage[language]} (${language})`);
}
}

View File

@@ -1,18 +0,0 @@
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
export interface CodeValidationAnalyzer {
(
lines: readonly CodeLine[],
language: ScriptingLanguage,
): InvalidCodeLine[];
}
export interface InvalidCodeLine {
readonly lineNumber: number;
readonly error: string;
}
export interface CodeLine {
readonly lineNumber: number;
readonly text: string;
}

View File

@@ -1,4 +0,0 @@
export interface LanguageSyntax {
readonly commentDelimiters: readonly string[];
readonly commonCodeParts: readonly string[];
}

View File

@@ -1,7 +0,0 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
export class ShellScriptSyntax implements LanguageSyntax {
public readonly commentDelimiters = ['#'];
public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done'];
}

View File

@@ -1,19 +0,0 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import { BatchFileSyntax } from './BatchFileSyntax';
import { ShellScriptSyntax } from './ShellScriptSyntax';
export interface SyntaxFactory {
(language: ScriptingLanguage): LanguageSyntax;
}
export const createSyntax: SyntaxFactory = (language: ScriptingLanguage): LanguageSyntax => {
switch (language) {
case ScriptingLanguage.batchfile:
return new BatchFileSyntax();
case ScriptingLanguage.shellscript:
return new ShellScriptSyntax();
default:
throw new RangeError(`Invalid language: "${ScriptingLanguage[language]}"`);
}
};

View File

@@ -1,5 +0,0 @@
export enum CodeValidationRule {
NoEmptyLines,
NoDuplicatedLines,
NoTooLongLines,
}

View File

@@ -1,78 +0,0 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createValidationAnalyzers, type ValidationRuleAnalyzerFactory } from './ValidationRuleAnalyzerFactory';
import type { CodeLine, InvalidCodeLine } from './Analyzers/CodeValidationAnalyzer';
import type { CodeValidationRule } from './CodeValidationRule';
export interface CodeValidator {
(
code: string,
language: ScriptingLanguage,
rules: readonly CodeValidationRule[],
analyzerFactory?: ValidationRuleAnalyzerFactory,
): void;
}
export const validateCode: CodeValidator = (
code,
language,
rules,
analyzerFactory = createValidationAnalyzers,
) => {
const analyzers = analyzerFactory(rules);
if (!code) {
return;
}
const lines = extractLines(code);
const invalidLines = analyzers.flatMap((analyze) => analyze(lines, language));
if (invalidLines.length === 0) {
return;
}
const errorText = `Errors with the code.\n${formatLines(lines, invalidLines)}`;
throw new Error(errorText);
};
function extractLines(code: string): CodeLine[] {
const lines = splitTextIntoLines(code);
return lines.map((lineText, lineIndex): CodeLine => ({
lineNumber: lineIndex + 1,
text: lineText,
}));
}
function formatLines(
lines: readonly CodeLine[],
invalidLines: readonly InvalidCodeLine[],
): string {
return lines.map((line) => {
const badLine = invalidLines.find((invalidLine) => invalidLine.lineNumber === line.lineNumber);
return formatLine({
lineNumber: line.lineNumber,
text: line.text,
error: badLine?.error,
});
}).join('\n');
}
function formatLine(
line: {
readonly lineNumber: number;
readonly text: string;
readonly error?: string;
},
): string {
let text = `[${line.lineNumber}] `;
text += line.error ? '❌' : '✅';
text += ` ${trimLine(line.text)}`;
if (line.error) {
text += `\n\t⟶ ${line.error}`;
}
return text;
}
function trimLine(line: string) {
const maxLength = 500;
if (line.length > maxLength) {
line = `${line.substring(0, maxLength)}... [Rest of the line trimmed]`;
}
return line;
}

View File

@@ -1,47 +0,0 @@
import { CodeValidationRule } from './CodeValidationRule';
import { analyzeDuplicateLines } from './Analyzers/AnalyzeDuplicateLines';
import { analyzeEmptyLines } from './Analyzers/AnalyzeEmptyLines';
import { analyzeTooLongLines } from './Analyzers/AnalyzeTooLongLines';
import type { CodeValidationAnalyzer } from './Analyzers/CodeValidationAnalyzer';
export interface ValidationRuleAnalyzerFactory {
(
rules: readonly CodeValidationRule[],
): CodeValidationAnalyzer[];
}
export const createValidationAnalyzers: ValidationRuleAnalyzerFactory = (
rules,
): CodeValidationAnalyzer[] => {
if (rules.length === 0) { throw new Error('missing rules'); }
validateUniqueRules(rules);
return rules.map((rule) => createValidationRule(rule));
};
function createValidationRule(rule: CodeValidationRule): CodeValidationAnalyzer {
switch (rule) {
case CodeValidationRule.NoEmptyLines:
return analyzeEmptyLines;
case CodeValidationRule.NoDuplicatedLines:
return analyzeDuplicateLines;
case CodeValidationRule.NoTooLongLines:
return analyzeTooLongLines;
default:
throw new Error(`Unknown rule: ${rule}`);
}
}
function validateUniqueRules(
rules: readonly CodeValidationRule[],
): void {
const ruleCounts = new Map<CodeValidationRule, number>();
rules.forEach((rule) => {
ruleCounts.set(rule, (ruleCounts.get(rule) || 0) + 1);
});
const duplicates = Array.from(ruleCounts.entries())
.filter(([, count]) => count > 1)
.map(([rule, count]) => `${CodeValidationRule[rule]} (${count} times)`);
if (duplicates.length > 0) {
throw new Error(`Duplicate rules are not allowed. Duplicates found: ${duplicates.join(', ')}`);
}
}

View File

@@ -1,24 +0,0 @@
import type { CategoryData, ScriptData, ExecutableData } from '@/application/collections/';
import { ExecutableType } from './ExecutableType';
export type ExecutableErrorContext = {
readonly parentCategory?: CategoryData;
} & (CategoryErrorContext | ScriptErrorContext | UnknownExecutableErrorContext);
export type CategoryErrorContext = {
readonly type: ExecutableType.Category;
readonly self: CategoryData;
readonly parentCategory?: CategoryData;
};
export type ScriptErrorContext = {
readonly type: ExecutableType.Script;
readonly self: ScriptData;
readonly parentCategory?: CategoryData;
};
export type UnknownExecutableErrorContext = {
readonly type?: undefined;
readonly self: ExecutableData;
readonly parentCategory?: CategoryData;
};

View File

@@ -1,43 +0,0 @@
import type { ExecutableData } from '@/application/collections/';
import { ExecutableType } from './ExecutableType';
import type { ExecutableErrorContext } from './ExecutableErrorContext';
export interface ExecutableContextErrorMessageCreator {
(
errorMessage: string,
context: ExecutableErrorContext,
): string;
}
export const createExecutableContextErrorMessage: ExecutableContextErrorMessageCreator = (
errorMessage,
context,
) => {
let message = '';
if (context.type !== undefined) {
message += `${ExecutableType[context.type]}: `;
}
message += errorMessage;
message += `\n\n${getErrorContextDetails(context)}`;
return message;
};
function getErrorContextDetails(context: ExecutableErrorContext): string {
let output = `Executable: ${formatExecutable(context.self)}`;
if (context.parentCategory) {
output += `\n\nParent category: ${formatExecutable(context.parentCategory)}`;
}
return output;
}
function formatExecutable(executable: ExecutableData): string {
if (!executable) {
return 'Executable data is missing.';
}
const maxLength = 1000;
let output = JSON.stringify(executable, undefined, 2);
if (output.length > maxLength) {
output = `${output.substring(0, maxLength)}\n... [Rest of the executable trimmed]`;
}
return output;
}

View File

@@ -1,4 +0,0 @@
export enum ExecutableType {
Script,
Category,
}

View File

@@ -1,69 +0,0 @@
import { isString } from '@/TypeHelpers';
import { createTypeValidator, type TypeValidator } from '../../Common/TypeValidator';
import { type ExecutableErrorContext } from './ExecutableErrorContext';
import { createExecutableContextErrorMessage, type ExecutableContextErrorMessageCreator } from './ExecutableErrorContextMessage';
export interface ExecutableValidatorFactory {
(context: ExecutableErrorContext): ExecutableValidator;
}
type AssertTypeFunction = (validator: TypeValidator) => void;
export interface ExecutableValidator {
assertValidName(nameValue: string): void;
assertType(assert: AssertTypeFunction): void;
assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true);
createContextualErrorMessage(errorMessage: string): string;
}
export const createExecutableDataValidator
: ExecutableValidatorFactory = (context) => new ContextualExecutableValidator(context);
export class ContextualExecutableValidator implements ExecutableValidator {
constructor(
private readonly context: ExecutableErrorContext,
private readonly createErrorMessage
: ExecutableContextErrorMessageCreator = createExecutableContextErrorMessage,
private readonly validator: TypeValidator = createTypeValidator(),
) {
}
public assertValidName(nameValue: string): void {
this.assert(() => Boolean(nameValue), 'missing name');
this.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertType(assert: AssertTypeFunction): void {
try {
assert(this.validator);
} catch (error) {
this.throw(error.message);
}
}
public assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
}
public createContextualErrorMessage(errorMessage: string): string {
return this.createErrorMessage(errorMessage, this.context);
}
private throw(errorMessage: string): never {
throw new Error(
this.createContextualErrorMessage(errorMessage),
);
}
}

View File

@@ -0,0 +1,3 @@
import type { ScriptData, CategoryData } from '@/application/collections/';
export type NodeData = CategoryData | ScriptData;

View File

@@ -0,0 +1,25 @@
import type { CategoryData, ScriptData } from '@/application/collections/';
import { NodeDataType } from './NodeDataType';
import type { NodeData } from './NodeData';
export type NodeDataErrorContext = {
readonly parentNode?: CategoryData;
} & (CategoryNodeErrorContext | ScriptNodeErrorContext | UnknownNodeErrorContext);
export type CategoryNodeErrorContext = {
readonly type: NodeDataType.Category;
readonly selfNode: CategoryData;
readonly parentNode?: CategoryData;
};
export type ScriptNodeErrorContext = {
readonly type: NodeDataType.Script;
readonly selfNode: ScriptData;
readonly parentNode?: CategoryData;
};
export type UnknownNodeErrorContext = {
readonly type?: undefined;
readonly selfNode: NodeData;
readonly parentNode?: CategoryData;
};

View File

@@ -0,0 +1,35 @@
import { NodeDataType } from './NodeDataType';
import type { NodeDataErrorContext } from './NodeDataErrorContext';
import type { NodeData } from './NodeData';
export interface NodeContextErrorMessageCreator {
(
errorMessage: string,
context: NodeDataErrorContext,
): string;
}
export const createNodeContextErrorMessage: NodeContextErrorMessageCreator = (
errorMessage,
context,
) => {
let message = '';
if (context.type !== undefined) {
message += `${NodeDataType[context.type]}: `;
}
message += errorMessage;
message += `\n${getErrorContextDetails(context)}`;
return message;
};
function getErrorContextDetails(context: NodeDataErrorContext): string {
let output = `Self: ${printNodeDataAsJson(context.selfNode)}`;
if (context.parentNode) {
output += `\nParent: ${printNodeDataAsJson(context.parentNode)}`;
}
return output;
}
function printNodeDataAsJson(node: NodeData): string {
return JSON.stringify(node, undefined, 2);
}

View File

@@ -0,0 +1,4 @@
export enum NodeDataType {
Script,
Category,
}

View File

@@ -0,0 +1,69 @@
import { isString } from '@/TypeHelpers';
import { type NodeDataErrorContext } from './NodeDataErrorContext';
import { createNodeContextErrorMessage, type NodeContextErrorMessageCreator } from './NodeDataErrorContextMessage';
import type { NodeData } from './NodeData';
export interface NodeDataValidatorFactory {
(context: NodeDataErrorContext): NodeDataValidator;
}
export interface NodeDataValidator {
assertValidName(nameValue: string): void;
assertDefined(
node: NodeData | undefined,
): asserts node is NonNullable<NodeData> & void;
assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true);
createContextualErrorMessage(errorMessage: string): string;
}
export const createNodeDataValidator
: NodeDataValidatorFactory = (context) => new ContextualNodeDataValidator(context);
export class ContextualNodeDataValidator implements NodeDataValidator {
constructor(
private readonly context: NodeDataErrorContext,
private readonly createErrorMessage
: NodeContextErrorMessageCreator = createNodeContextErrorMessage,
) {
}
public assertValidName(nameValue: string): void {
this.assert(() => Boolean(nameValue), 'missing name');
this.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertDefined(
node: NodeData,
): asserts node is NonNullable<NodeData> {
this.assert(
() => node !== undefined && node !== null && Object.keys(node).length > 0,
'missing node data',
);
}
public assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
}
public createContextualErrorMessage(errorMessage: string): string {
return this.createErrorMessage(errorMessage, this.context);
}
private throw(errorMessage: string): never {
throw new Error(
this.createContextualErrorMessage(errorMessage),
);
}
}

View File

@@ -24,10 +24,6 @@ parseProjectDetails(
);
}
export interface ProjectDetailsParser {
(): ProjectDetails;
}
export type ProjectDetailsFactory = (
...args: ConstructorArguments<typeof GitHubProjectDetails>
) => ProjectDetails;

View File

@@ -0,0 +1,23 @@
import type { FunctionData } from '@/application/collections/';
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptCompiler } from './Compiler/ScriptCompiler';
import { SyntaxFactory } from './Validation/Syntax/SyntaxFactory';
import type { ILanguageSyntax } from './Validation/Syntax/ILanguageSyntax';
import type { IScriptCompiler } from './Compiler/IScriptCompiler';
import type { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
import type { ISyntaxFactory } from './Validation/Syntax/ISyntaxFactory';
export class CategoryCollectionParseContext implements ICategoryCollectionParseContext {
public readonly compiler: IScriptCompiler;
public readonly syntax: ILanguageSyntax;
constructor(
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory: ISyntaxFactory = new SyntaxFactory(),
) {
this.syntax = syntaxFactory.create(scripting.language);
this.compiler = new ScriptCompiler(functionsData ?? [], this.syntax);
}
}

View File

@@ -1,4 +1,4 @@
import { FunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { FunctionCallArgumentCollection } from '../../Function/Call/Argument/FunctionCallArgumentCollection';
import { ExpressionEvaluationContext, type IExpressionEvaluationContext } from './ExpressionEvaluationContext';
import { ExpressionPosition } from './ExpressionPosition';

View File

@@ -1,4 +1,4 @@
import { type IExpressionEvaluationContext, ExpressionEvaluationContext } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
import { type IExpressionEvaluationContext, ExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
import { CompositeExpressionParser } from './Parser/CompositeExpressionParser';
import type { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection';
import type { IExpressionsCompiler } from './IExpressionsCompiler';

View File

@@ -1,10 +1,10 @@
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { Expression, type ExpressionEvaluator } from '../../Expression/Expression';
import { createPositionFromRegexFullMatch, type ExpressionPositionFactory } from '../../Expression/ExpressionPositionFactory';
import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from '../../../Function/Parameter/FunctionParameterCollectionFactory';
import type { IExpressionParser } from '../IExpressionParser';
import type { IExpression } from '../../Expression/IExpression';
import type { FunctionParameter } from '../../../Function/Parameter/FunctionParameter';
import type { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter';
import type { IFunctionParameterCollection, IReadOnlyFunctionParameterCollection } from '../../../Function/Parameter/IFunctionParameterCollection';
export interface RegexParserUtilities {
@@ -110,7 +110,7 @@ function createParameters(
export interface PrimitiveExpression {
readonly evaluator: ExpressionEvaluator;
readonly parameters?: readonly FunctionParameter[];
readonly parameters?: readonly IFunctionParameter[];
}
export interface ExpressionFactory {

View File

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

View File

@@ -1,11 +1,11 @@
import type { Pipe } from '../Pipe';
import type { IPipe } from '../IPipe';
export class EscapeDoubleQuotes implements Pipe {
export class EscapeDoubleQuotes implements IPipe {
public readonly name: string = 'escapeDoubleQuotes';
public apply(raw: string): string {
if (!raw) {
return '';
return raw;
}
return raw.replaceAll('"', '"^""');
/* eslint-disable vue/max-len */

View File

@@ -1,7 +1,6 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { Pipe } from '../Pipe';
import type { IPipe } from '../IPipe';
export class InlinePowerShell implements Pipe {
export class InlinePowerShell implements IPipe {
public readonly name: string = 'inlinePowerShell';
public apply(code: string): string {
@@ -9,11 +8,9 @@ export class InlinePowerShell implements Pipe {
return code;
}
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
// Order is important
inlineComments,
mergeHereStrings,
mergeLinesWithBacktick,
mergeLinesWithBracketCodeBlocks,
mergeHereStrings,
mergeNewLines,
]).reduce((a, b) => (data) => b(a(data)));
const newCode = processor(code);
@@ -92,6 +89,10 @@ function inlineComments(code: string): string {
*/
}
function getLines(code: string): string[] {
return (code?.split(/\r\n|\r|\n/) || []);
}
/*
Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings
@@ -101,18 +102,18 @@ function mergeHereStrings(code: string) {
return code.replaceAll(regex, (_$, quotes, scope) => {
const newString = getHereStringHandler(quotes);
const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
const lines = splitTextIntoLines(escaped);
const lines = getLines(escaped);
const inlined = lines.join(newString.separator);
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
return quoted;
});
}
interface InlinedHereString {
interface IInlinedHereString {
readonly quotesAround: string;
readonly escapedQuotes: string;
readonly separator: string;
}
function getHereStringHandler(quotes: string): InlinedHereString {
function getHereStringHandler(quotes: string): IInlinedHereString {
/*
We handle @' and @" differently.
Single quotes are interpreted literally and doubles are expandable.
@@ -157,33 +158,9 @@ function mergeLinesWithBacktick(code: string) {
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
}
/**
* Inlines code blocks in PowerShell scripts while preserving correct syntax.
* It removes unnecessary newlines and spaces around brackets,
* inlining the code where possible.
* This prevents syntax errors like "Unexpected token '}'" when inlining brackets.
*/
function mergeLinesWithBracketCodeBlocks(code: string): string {
return code
// Opening bracket: [whitespace] Opening bracket (newline)
.replace(/(?<=.*)\s*{[\r\n][\s\r\n]*/g, ' { ')
// Closing bracket: [whitespace] Closing bracket (newline) (continuation keyword)
.replace(/\s*}[\r\n][\s\r\n]*(?=elseif|else|catch|finally|until)/g, ' } ')
.replace(/(?<=do\s*{.*)[\r\n\s]*}[\r\n][\r\n\s]*(?=while)/g, ' } '); // Do-While
}
function mergeNewLines(code: string) {
const nonEmptyLines = splitTextIntoLines(code)
return getLines(code)
.map((line) => line.trim())
.filter((line) => line.length > 0);
return nonEmptyLines
.map((line, index) => {
const isLastLine = index === nonEmptyLines.length - 1;
if (isLastLine) {
return line;
}
return line.endsWith(';') ? line : `${line};`;
})
.join(' ');
.filter((line) => line.length > 0)
.join('; ');
}

View File

@@ -1,6 +1,6 @@
import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
import type { Pipe } from './Pipe';
import type { IPipe } from './IPipe';
const RegisteredPipes = [
new EscapeDoubleQuotes(),
@@ -8,19 +8,19 @@ const RegisteredPipes = [
];
export interface IPipeFactory {
get(pipeName: string): Pipe;
get(pipeName: string): IPipe;
}
export class PipeFactory implements IPipeFactory {
private readonly pipes = new Map<string, Pipe>();
private readonly pipes = new Map<string, IPipe>();
constructor(pipes: readonly Pipe[] = RegisteredPipes) {
constructor(pipes: readonly IPipe[] = RegisteredPipes) {
for (const pipe of pipes) {
this.registerPipe(pipe);
}
}
public get(pipeName: string): Pipe {
public get(pipeName: string): IPipe {
validatePipeName(pipeName);
const pipe = this.pipes.get(pipeName);
if (!pipe) {
@@ -29,7 +29,7 @@ export class PipeFactory implements IPipeFactory {
return pipe;
}
private registerPipe(pipe: Pipe): void {
private registerPipe(pipe: IPipe): void {
validatePipeName(pipe.name);
if (this.pipes.has(pipe.name)) {
throw new Error(`Pipe name must be unique: "${pipe.name}"`);

View File

@@ -1,3 +1,4 @@
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, type PrimitiveExpression } from '../Parser/Regex/RegexParser';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
@@ -15,10 +16,7 @@ export class ParameterSubstitutionParser extends RegexParser {
const parameterName = match[1];
const pipeline = match[2];
return {
parameters: [{
name: parameterName,
isOptional: false,
}],
parameters: [new FunctionParameter(parameterName, false)],
evaluator: (context) => {
const { argumentValue } = context.args.getArgument(parameterName);
if (!pipeline) {

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