Compare commits

..

1 Commits

Author SHA1 Message Date
undergroundwires
f9a54c7e68 Fix Colima builds failing 2024-05-20 17:32:21 +02:00
607 changed files with 16650 additions and 47080 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

@@ -1,15 +0,0 @@
inputs:
name:
required: true
path:
required: true
runs:
using: composite
steps:
-
name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.name }}
path: ${{ inputs.path }}

View File

@@ -86,9 +86,21 @@ jobs:
name: Install Docker on macOS
if: contains(matrix.os, 'macos') # macOS runner is missing Docker
run: |-
# Verify Intel-based macOS
arch=$(uname -m)
case "$arch" in
i386|x86_64)
echo "Supported architecture: $arch"
;;
*)
>&2 echo 'The macOS is not running on a supported Intel architecture. Virtualization is not supported.'
exit 1
;;
esac
# Install Docker
brew install docker
# Docker on macOS misses daemon due to licensing, so install colima as runtime
# Docker on macOS does not include the Docker daemon due to licensing issues.
# Install Colima to use as the Docker runtime.
brew install colima
# Start the daemon
colima start

View File

@@ -10,8 +10,8 @@ jobs:
strategy:
matrix:
os:
- macos-latest # Apple silicon (ARM64)
- macos-13 # Intel-based (x86-64)
- macos-latest # Latest Apple silicon (ARM64)
- macos-12 # Latest Intel-based (x86-64)
- ubuntu-latest
- windows-latest
fail-fast: false # Allows to see results from other combinations
@@ -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
@@ -82,7 +70,7 @@ jobs:
-
name: Upload screenshot
if: always() # Run even if previous step fails
uses: ./.github/actions/upload-artifact
uses: actions/upload-artifact@v3
with:
name: screenshot-${{ matrix.os }}
path: screenshot.png

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

View File

@@ -51,14 +51,14 @@ jobs:
-
name: Upload screenshots
if: failure() # Run only if previous steps fail because screenshots will be generated only if E2E test failed
uses: ./.github/actions/upload-artifact
uses: actions/upload-artifact@v3
with:
name: e2e-screenshots-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }}
-
name: Upload videos
if: always() # Run even if previous steps fail because test run video is always captured
uses: ./.github/actions/upload-artifact
uses: actions/upload-artifact@v3
with:
name: e2e-videos-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.VIDEOS_DIR }}

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,87 +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)
* ci/cd: fix recent Docker build failures on macOS | [a1922c5](https://github.com/undergroundwires/privacy.sexy/commit/a1922c50c12b3b7806e9e681ace842194a178bda)
* win: standardize registry edit + delete on revert | [cec0b4b](https://github.com/undergroundwires/privacy.sexy/commit/cec0b4b4f63c3563a0e7923ce6324a38d71a3955)
* Fix e2e test failing on Windows | [4a7efa2](https://github.com/undergroundwires/privacy.sexy/commit/4a7efa27c8df73ef9b7960afed29f216b066cba2)
* Add support for macOS universal binary #348, #362 | [d25c4e8](https://github.com/undergroundwires/privacy.sexy/commit/d25c4e8c812b8d012010ba38070a2931dcd28908)
* Migrate to GitHub issue forms | [9ab3ff7](https://github.com/undergroundwires/privacy.sexy/commit/9ab3ff75b0a69ac2ba27dd02e82db9b5bd76ea0f)
* ci/cd: fix quality checks not running on all OSes | [2390530](https://github.com/undergroundwires/privacy.sexy/commit/2390530d929fb92c266558c52376569a0ecb90c1)
* Bump Vue to latest and fix universal selector CSS | [aae5434](https://github.com/undergroundwires/privacy.sexy/commit/aae54344511ec51d17ad0420a92cb5a064e0e7bb)
* Centralize and optimize `ResizeObserver` usage | [2923621](https://github.com/undergroundwires/privacy.sexy/commit/292362135db0519ec1050bab80ed373aad115731)
* win: improve app access disabling and docs #138 | [ff3d5c4](https://github.com/undergroundwires/privacy.sexy/commit/ff3d5c48419f663379f5aba8936636c22f2c5de8)
* win: document and discourage RSA key script #363 | [f347fde](https://github.com/undergroundwires/privacy.sexy/commit/f347fde0c85f8b51b0060fdea0a2724b042aaeed)
* win: improve printing removal /w Print Queue #279 | [150e067](https://github.com/undergroundwires/privacy.sexy/commit/150e0670392bb62348c20ec644a4ed8a6bbffe74)
* win: discourage blocking app access #121 #339 #350 | [7794846](https://github.com/undergroundwires/privacy.sexy/commit/77948461856e6837ddfbcbbef72a1bf9fc706b4e)
* Improve context for errors thrown by compiler | [4212c7b](https://github.com/undergroundwires/privacy.sexy/commit/4212c7b9e0b1500378a1e4e88efc2d59f39f3d29)
* win: document disabling firewall #115 #152 #364 | [12b1f18](https://github.com/undergroundwires/privacy.sexy/commit/12b1f183f7ce966d6ce090d98aeea7ec491f8c7c)
* win: add script to disable Recall feature | [ce4cfdd](https://github.com/undergroundwires/privacy.sexy/commit/ce4cfdd169b7da0edc3da61143c988ed5f3c976e)
* win, mac, linux: fix typos and dead URLs #367 | [9e34e64](https://github.com/undergroundwires/privacy.sexy/commit/9e34e644493674ca709b64a47206763d5d4bd60c)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.3...0.13.4)
## 0.13.3 (2024-05-11)
* win: organize and document network disablement | [2eed6f4](https://github.com/undergroundwires/privacy.sexy/commit/2eed6f4afb6cf85fdc1d6acb808f82405a35cafd)

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.3/privacy.sexy-Setup-0.13.3.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.3/privacy.sexy-0.13.3.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.3/privacy.sexy-0.13.3.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.3",
"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

@@ -1,4 +1,4 @@
import { isFunction, type ConstructorArguments } from '@/TypeHelpers';
import { isFunction } from '@/TypeHelpers';
/*
Provides a unified and resilient way to extend errors across platforms.
@@ -12,8 +12,8 @@ import { isFunction, type ConstructorArguments } from '@/TypeHelpers';
> https://web.archive.org/web/20230810014143/https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
*/
export abstract class CustomError extends Error {
constructor(...args: ConstructorArguments<typeof Error>) {
super(...args);
constructor(message?: string, options?: ErrorOptions) {
super(message, options);
fixPrototype(this, new.target.prototype);
ensureStackTrace(this);

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,133 @@
import type {
CategoryData, ScriptData, CategoryOrScriptData,
} from '@/application/collections/';
import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category';
import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { parseDocs } from './DocumentationParser';
import { parseScript } from './Script/ScriptParser';
import type { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
let categoryIdCounter = 0;
export function parseCategory(
category: CategoryData,
context: ICategoryCollectionParseContext,
factory: CategoryFactoryType = CategoryFactory,
): Category {
return parseCategoryRecursively({
categoryData: category,
context,
factory,
});
}
interface ICategoryParseContext {
readonly categoryData: CategoryData,
readonly context: ICategoryCollectionParseContext,
readonly factory: CategoryFactoryType,
readonly parentCategory?: CategoryData,
}
function parseCategoryRecursively(context: ICategoryParseContext): Category | never {
ensureValidCategory(context.categoryData, context.parentCategory);
const children: ICategoryChildren = {
subCategories: new Array<Category>(),
subScripts: new Array<Script>(),
};
for (const data of context.categoryData.children) {
parseNode({
nodeData: data,
children,
parent: context.categoryData,
factory: context.factory,
context: context.context,
});
}
try {
return context.factory(
/* id: */ categoryIdCounter++,
/* name: */ context.categoryData.category,
/* docs: */ parseDocs(context.categoryData),
/* categories: */ children.subCategories,
/* scripts: */ children.subScripts,
);
} catch (err) {
return new NodeValidator({
type: NodeType.Category,
selfNode: context.categoryData,
parentNode: context.parentCategory,
}).throw(err.message);
}
}
function ensureValidCategory(category: CategoryData, parentCategory?: CategoryData) {
new NodeValidator({
type: NodeType.Category,
selfNode: category,
parentNode: parentCategory,
})
.assertDefined(category)
.assertValidName(category.category)
.assert(
() => category.children.length > 0,
`"${category.category}" has no children.`,
);
}
interface ICategoryChildren {
subCategories: Category[];
subScripts: Script[];
}
interface INodeParseContext {
readonly nodeData: CategoryOrScriptData;
readonly children: ICategoryChildren;
readonly parent: CategoryData;
readonly factory: CategoryFactoryType;
readonly context: ICategoryCollectionParseContext;
}
function parseNode(context: INodeParseContext) {
const validator = new NodeValidator({ selfNode: context.nodeData, parentNode: context.parent });
validator.assertDefined(context.nodeData);
if (isCategory(context.nodeData)) {
const subCategory = parseCategoryRecursively({
categoryData: context.nodeData,
context: context.context,
factory: context.factory,
parentCategory: context.parent,
});
context.children.subCategories.push(subCategory);
} else if (isScript(context.nodeData)) {
const script = parseScript(context.nodeData, context.context);
context.children.subScripts.push(script);
} else {
validator.throw('Node is neither a category or a 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) {
return Object.prototype.hasOwnProperty.call(object, propertyName);
}
export type CategoryFactoryType = (
...parameters: ConstructorParameters<typeof Category>) => Category;
const CategoryFactory: CategoryFactoryType = (...parameters) => new Category(...parameters);

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

@@ -1,7 +1,7 @@
import type { DocumentableData, DocumentationData } from '@/application/collections/';
import { isString, isArray } from '@/TypeHelpers';
export const parseDocs: DocsParser = (documentable) => {
export function parseDocs(documentable: DocumentableData): readonly string[] {
const { docs } = documentable;
if (!docs) {
return [];
@@ -9,12 +9,6 @@ export const parseDocs: DocsParser = (documentable) => {
let result = new DocumentationContainer();
result = addDocs(docs, result);
return result.getAll();
};
export interface DocsParser {
(
documentable: DocumentableData,
): readonly string[];
}
function addDocs(
@@ -50,5 +44,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,127 +0,0 @@
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/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 { IFunctionParameterCollection, IReadOnlyFunctionParameterCollection } from '../../../Function/Parameter/IFunctionParameterCollection';
export interface RegexParserUtilities {
readonly wrapError: ErrorWithContextWrapper;
readonly createPosition: ExpressionPositionFactory;
readonly createExpression: ExpressionFactory;
readonly createParameterCollection: FunctionParameterCollectionFactory;
}
export abstract class RegexParser implements IExpressionParser {
protected abstract readonly regex: RegExp;
public constructor(
private readonly utilities: RegexParserUtilities = DefaultRegexParserUtilities,
) {
}
public findExpressions(code: string): IExpression[] {
return Array.from(this.findRegexExpressions(code));
}
protected abstract buildExpression(match: RegExpMatchArray): PrimitiveExpression;
private* findRegexExpressions(code: string): Iterable<IExpression> {
if (!code) {
throw new Error(
this.buildErrorMessageWithContext({ errorMessage: 'missing code', code: 'EMPTY' }),
);
}
const createErrorContext = (message: string): ErrorContext => ({ code, errorMessage: message });
const matches = this.doOrRethrow(
() => code.matchAll(this.regex),
createErrorContext('Failed to match regex.'),
);
for (const match of matches) {
const primitiveExpression = this.doOrRethrow(
() => this.buildExpression(match),
createErrorContext('Failed to build expression.'),
);
const position = this.doOrRethrow(
() => this.utilities.createPosition(match),
createErrorContext('Failed to create position.'),
);
const parameters = this.doOrRethrow(
() => createParameters(
primitiveExpression,
this.utilities.createParameterCollection(),
),
createErrorContext('Failed to create parameters.'),
);
const expression = this.doOrRethrow(
() => this.utilities.createExpression({
position,
evaluator: primitiveExpression.evaluator,
parameters,
}),
createErrorContext('Failed to create expression.'),
);
yield expression;
}
}
private doOrRethrow<T>(
action: () => T,
context: ErrorContext,
): T {
try {
return action();
} catch (error) {
throw this.utilities.wrapError(
error,
this.buildErrorMessageWithContext(context),
);
}
}
private buildErrorMessageWithContext(context: ErrorContext): string {
return [
context.errorMessage,
`Class name: ${this.constructor.name}`,
`Regex pattern used: ${this.regex}`,
`Code: ${context.code}`,
].join('\n');
}
}
interface ErrorContext {
readonly errorMessage: string,
readonly code: string,
}
function createParameters(
expression: PrimitiveExpression,
parameterCollection: IFunctionParameterCollection,
): IReadOnlyFunctionParameterCollection {
return (expression.parameters || [])
.reduce((parameters, parameter) => {
parameters.addParameter(parameter);
return parameters;
}, parameterCollection);
}
export interface PrimitiveExpression {
readonly evaluator: ExpressionEvaluator;
readonly parameters?: readonly FunctionParameter[];
}
export interface ExpressionFactory {
(
...args: ConstructorParameters<typeof Expression>
): IExpression;
}
const DefaultRegexParserUtilities: RegexParserUtilities = {
wrapError: wrapErrorWithAdditionalContext,
createPosition: createPositionFromRegexFullMatch,
createExpression: (...args) => new Expression(...args),
createParameterCollection: createFunctionParameterCollection,
};

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,12 +0,0 @@
import { FunctionParameterCollection } from './FunctionParameterCollection';
import type { IFunctionParameterCollection } from './IFunctionParameterCollection';
export interface FunctionParameterCollectionFactory {
(
...args: ConstructorParameters<typeof FunctionParameterCollection>
): IFunctionParameterCollection;
}
export const createFunctionParameterCollection: FunctionParameterCollectionFactory = (...args) => {
return new FunctionParameterCollection(...args);
};

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,34 @@
import { CustomError } from '@/application/Common/CustomError';
import { NodeType } from './NodeType';
import type { NodeData } from './NodeData';
export class NodeDataError extends CustomError {
constructor(message: string, public readonly context: INodeDataErrorContext) {
super(createMessage(message, context));
}
}
export interface INodeDataErrorContext {
readonly type?: NodeType;
readonly selfNode: NodeData;
readonly parentNode?: NodeData;
}
function createMessage(errorMessage: string, context: INodeDataErrorContext) {
let message = '';
if (context.type !== undefined) {
message += `${NodeType[context.type]}: `;
}
message += errorMessage;
message += `\n${dump(context)}`;
return message;
}
function dump(context: INodeDataErrorContext): string {
const printJson = (obj: unknown) => JSON.stringify(obj, undefined, 2);
let output = `Self: ${printJson(context.selfNode)}`;
if (context.parentNode) {
output += `\nParent: ${printJson(context.parentNode)}`;
}
return output;
}

View File

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

View File

@@ -0,0 +1,39 @@
import { isString } from '@/TypeHelpers';
import { type INodeDataErrorContext, NodeDataError } from './NodeDataError';
import type { NodeData } from './NodeData';
export class NodeValidator {
constructor(private readonly context: INodeDataErrorContext) {
}
public assertValidName(nameValue: string) {
return this
.assert(
() => Boolean(nameValue),
'missing name',
)
.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertDefined(node: NodeData) {
return this.assert(
() => node !== undefined && node !== null && Object.keys(node).length > 0,
'missing node data',
);
}
public assert(validationPredicate: () => boolean, errorMessage: string) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
return this;
}
public throw(errorMessage: string): never {
throw new NodeDataError(errorMessage, this.context);
}
}

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';
@@ -7,18 +7,15 @@ import type { IReadOnlyFunctionParameterCollection } from '../../Function/Parame
import type { IExpression } from './IExpression';
export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string;
export class Expression implements IExpression {
public readonly parameters: IReadOnlyFunctionParameterCollection;
public readonly position: ExpressionPosition;
public readonly evaluator: ExpressionEvaluator;
constructor(parameters: ExpressionInitParameters) {
this.parameters = parameters.parameters ?? new FunctionParameterCollection();
this.evaluator = parameters.evaluator;
this.position = parameters.position;
constructor(
public readonly position: ExpressionPosition,
public readonly evaluator: ExpressionEvaluator,
parameters?: IReadOnlyFunctionParameterCollection,
) {
this.parameters = parameters ?? new FunctionParameterCollection();
}
public evaluate(context: IExpressionEvaluationContext): string {
@@ -29,12 +26,6 @@ export class Expression implements IExpression {
}
}
export interface ExpressionInitParameters {
readonly position: ExpressionPosition,
readonly evaluator: ExpressionEvaluator,
readonly parameters?: IReadOnlyFunctionParameterCollection,
}
function validateThatAllRequiredParametersAreSatisfied(
parameters: IReadOnlyFunctionParameterCollection,
args: IReadOnlyFunctionCallArgumentCollection,

View File

@@ -1,13 +1,8 @@
import { ExpressionPosition } from './ExpressionPosition';
export interface ExpressionPositionFactory {
(
match: RegExpMatchArray,
): ExpressionPosition
}
export const createPositionFromRegexFullMatch
: ExpressionPositionFactory = (match) => {
export function createPositionFromRegexFullMatch(
match: RegExpMatchArray,
): ExpressionPosition {
const startPos = match.index;
if (startPos === undefined) {
throw new Error(`Regex match did not yield any results: ${JSON.stringify(match)}`);
@@ -18,4 +13,4 @@ export const createPositionFromRegexFullMatch
}
const endPos = startPos + fullMatch.length;
return new ExpressionPosition(startPos, endPos);
};
}

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

@@ -3,10 +3,10 @@ import { WithParser } from '../SyntaxParsers/WithParser';
import type { IExpression } from '../Expression/IExpression';
import type { IExpressionParser } from './IExpressionParser';
const Parsers: readonly IExpressionParser[] = [
const Parsers = [
new ParameterSubstitutionParser(),
new WithParser(),
] as const;
];
export class CompositeExpressionParser implements IExpressionParser {
public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {

View File

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

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 */

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