diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98669128..70a72fb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ - **Stateless**, extends `Vue` - **Stateful**, extends [`StatefulVue`](./src/presentation/StatefulVue.ts) - The source of truth for the state lies in application layer (`./src/application/`) and must be updated from the views if they're mutating the state - - They mutate or/and reacts to changes in [application state](src/application/State/ApplicationState.ts). + - They mutate or/and react to changes in [application state](src/application/State/ApplicationState.ts). - You can react by getting the state and listening to it and update the view accordingly in [`mounted()`](https://vuejs.org/v2/api/#mounted) method. ## License diff --git a/docs/application-file.md b/docs/application-file.md index 39df51b4..d2b1a5c3 100644 --- a/docs/application-file.md +++ b/docs/application-file.md @@ -10,16 +10,23 @@ ### `Application` -- Application file simply defines different categories and their scripts in a tree structure. +- Application file simply defines: + - different categories and their scripts in a tree structure + - OS specific details - Application file also allows defining common [function](#function)s to be used throughout the application if you'd like different scripts to share same code. #### `Application` syntax +- `os:` *`string`* (**required**) + - Operating system that the application file is written for. + - 📖 See [OperatingSystem.ts](./../src/domain/OperatingSystem.ts) enumeration for allowed values. - `actions: [` ***[`Category`](#Category)*** `, ... ]` **(required)** - Each [category](#category) is rendered as different cards in card presentation. - ❗ Application must consist of at least one category. - `functions: [` ***[`Function`](#Function)*** `, ... ]` - Functions are optionally defined to re-use the same code throughout different scripts. +- `scripting:` ***[`ScriptingDefinition`](#ScriptingDefinition)*** **(required)** + - Defines the scripting language that the code of other action uses. ### `Category` @@ -137,3 +144,18 @@ It would print "Hello world" if it's called in a [script](#script) as following: - Code that'll undo the change done by `code` property. - E.g. let's say `code` sets an environment variable as `setx POWERSHELL_TELEMETRY_OPTOUT 1` - then `revertCode` should be doing `setx POWERSHELL_TELEMETRY_OPTOUT 0` + +### `ScriptingDefinition` + +- Defines global properties for scripting that's used throughout the application file. + +#### `ScriptingDefinition` syntax + +- `language:` *`string`* (**required**) + - 📖 See [ScriptingLanguage.ts](./../src/domain/ScriptingLanguage.ts) enumeration for allowed values. +- `startCode:` *`string`* (**required**) + - Code that'll be inserted on top of user created script. + - Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!` +- `endCode:` *`string`* (**required**) + - Code that'll be inserted at the end of user created script. + - Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!` diff --git a/src/application/Common/Enum.ts b/src/application/Common/Enum.ts new file mode 100644 index 00000000..a2039856 --- /dev/null +++ b/src/application/Common/Enum.ts @@ -0,0 +1,43 @@ +// Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611 +type EnumType = number | string; +type EnumVariable = { [key in T]: TEnumValue }; + +export interface IEnumParser { + parseEnum(value: string, propertyName: string): TEnum; +} +export function createEnumParser( + enumVariable: EnumVariable): IEnumParser { + return { + parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable), + }; +} +function parseEnumValue( + value: string, + enumName: string, + enumVariable: EnumVariable): TEnumValue { + if (!value) { + throw new Error(`undefined ${enumName}`); + } + if (typeof value !== 'string') { + throw new Error(`unexpected type of ${enumName}: "${typeof value}"`); + } + const casedValue = getEnumNames(enumVariable) + .find((enumValue) => enumValue.toLowerCase() === value.toLowerCase()); + if (!casedValue) { + throw new Error(`unknown ${enumName}: "${value}"`); + } + return enumVariable[casedValue as keyof typeof enumVariable]; +} + +export function getEnumNames( + enumVariable: EnumVariable): string[] { + return Object + .values(enumVariable) + .filter((enumMember) => typeof enumMember === 'string') as string[]; +} + +export function getEnumValues( + enumVariable: EnumVariable): TEnumValue[] { + return getEnumNames(enumVariable) + .map((level) => enumVariable[level]) as TEnumValue[]; +} diff --git a/src/application/Parser/ApplicationParser.ts b/src/application/Parser/ApplicationParser.ts index 35c3eb0d..743366b0 100644 --- a/src/application/Parser/ApplicationParser.ts +++ b/src/application/Parser/ApplicationParser.ts @@ -1,14 +1,18 @@ import { Category } from '@/domain/Category'; import { Application } from '@/domain/Application'; import { IApplication } from '@/domain/IApplication'; -import { IProjectInformation } from '@/domain/IProjectInformation'; -import { ApplicationYaml } from 'js-yaml-loader!./../application.yaml'; +import { YamlApplication } from 'js-yaml-loader!./../application.yaml'; import { parseCategory } from './CategoryParser'; -import { ProjectInformation } from '@/domain/ProjectInformation'; +import { parseProjectInformation } from './ProjectInformationParser'; import { ScriptCompiler } from './Compiler/ScriptCompiler'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { parseScriptingDefinition } from './ScriptingDefinitionParser'; +import { createEnumParser } from '../Common/Enum'; - -export function parseApplication(content: ApplicationYaml, env: NodeJS.ProcessEnv = process.env): IApplication { +export function parseApplication( + content: YamlApplication, + env: NodeJS.ProcessEnv = process.env, + osParser = createEnumParser(OperatingSystem)): IApplication { validate(content); const compiler = new ScriptCompiler(content.functions); const categories = new Array(); @@ -16,23 +20,18 @@ export function parseApplication(content: ApplicationYaml, env: NodeJS.ProcessEn const category = parseCategory(action, compiler); categories.push(category); } - const info = readAppInformation(env); + const os = osParser.parseEnum(content.os, 'os'); + const info = parseProjectInformation(env); + const scripting = parseScriptingDefinition(content.scripting, info); const app = new Application( + os, info, - categories); + categories, + scripting); return app; } -function readAppInformation(environment: NodeJS.ProcessEnv): IProjectInformation { - return new ProjectInformation( - environment.VUE_APP_NAME, - environment.VUE_APP_VERSION, - environment.VUE_APP_REPOSITORY_URL, - environment.VUE_APP_HOMEPAGE_URL, - ); -} - -function validate(content: ApplicationYaml): void { +function validate(content: YamlApplication): void { if (!content) { throw new Error('application is null or undefined'); } diff --git a/src/application/Parser/Compiler/ILCode.ts b/src/application/Parser/Compiler/ILCode.ts new file mode 100644 index 00000000..4b190d8a --- /dev/null +++ b/src/application/Parser/Compiler/ILCode.ts @@ -0,0 +1,73 @@ +export interface IILCode { + compile(): string; + getUniqueParameterNames(): string[]; + substituteParameter(parameterName: string, parameterValue: string): IILCode; +} + +export function generateIlCode(rawText: string): IILCode { + const ilCode = generateIl(rawText); + return new ILCode(ilCode); +} + +class ILCode implements IILCode { + private readonly ilCode: string; + + constructor(ilCode: string) { + this.ilCode = ilCode; + } + + public substituteParameter(parameterName: string, parameterValue: string): IILCode { + const newCode = substituteParameter(this.ilCode, parameterName, parameterValue); + return new ILCode(newCode); + } + + public getUniqueParameterNames(): string[] { + return getUniqueParameterNames(this.ilCode); + } + + public compile(): string { + ensureNoExpressionLeft(this.ilCode); + return this.ilCode; + } +} + +// Trim each expression and put them inside "{{exp|}}" e.g. "{{ $hello }}" becomes "{{exp|$hello}}" +function generateIl(rawText: string): string { + return rawText.replace(/\{\{([\s]*[^;\s\{]+[\s]*)\}\}/g, (_, match) => { + return `\{\{exp|${match.trim()}\}\}`; + }); +} + +// finds all "{{exp|..}} left" +function ensureNoExpressionLeft(ilCode: string) { + const allSubstitutions = ilCode.matchAll(/\{\{exp\|(.*?)\}\}/g); + const allMatches = Array.from(allSubstitutions, (match) => match[1]); + const uniqueExpressions = getDistinctValues(allMatches); + if (uniqueExpressions.length > 0) { + throw new Error(`unknown expression: ${printList(uniqueExpressions)}`); + } +} + +// Parses all distinct usages of {{exp|$parameterName}} +function getUniqueParameterNames(ilCode: string) { + const allSubstitutions = ilCode.matchAll(/\{\{exp\|\$([^;\s\{]+[\s]*)\}\}/g); + const allParameters = Array.from(allSubstitutions, (match) => match[1]); + const uniqueParameterNames = getDistinctValues(allParameters); + return uniqueParameterNames; +} + +// substitutes {{exp|$parameterName}} to value of the parameter +function substituteParameter(ilCode: string, parameterName: string, parameterValue: string) { + const pattern = `{{exp|$${parameterName}}}`; + return ilCode.split(pattern).join(parameterValue); // as .replaceAll() is not yet supported by TS +} + +function getDistinctValues(values: readonly string[]): string[] { + return values.filter((value, index, self) => { + return self.indexOf(value) === index; + }); +} + +function printList(list: readonly string[]): string { + return `"${list.join('","')}"`; +} diff --git a/src/application/Parser/Compiler/ScriptCompiler.ts b/src/application/Parser/Compiler/ScriptCompiler.ts index aa009e89..513eaa5b 100644 --- a/src/application/Parser/Compiler/ScriptCompiler.ts +++ b/src/application/Parser/Compiler/ScriptCompiler.ts @@ -1,3 +1,4 @@ +import { generateIlCode, IILCode } from './ILCode'; import { IScriptCode } from '@/domain/IScriptCode'; import { ScriptCode } from '@/domain/ScriptCode'; import { YamlScript, YamlFunction, FunctionCall, ScriptFunctionCall, FunctionCallParameters } from 'js-yaml-loader!./application.yaml'; @@ -125,20 +126,22 @@ function compileCode(func: YamlFunction, parameters: FunctionCallParameters): IC } function compileExpressions(code: string, parameters: FunctionCallParameters): string { - let intermediateCode = compileToIL(code); + let intermediateCode = generateIlCode(code); intermediateCode = substituteParameters(intermediateCode, parameters); - ensureNoExpressionLeft(intermediateCode); - return intermediateCode; + return intermediateCode.compile(); } -function substituteParameters(intermediateCode: string, parameters: FunctionCallParameters): string { - const parameterNames = getUniqueParameterNamesFromIL(intermediateCode); +function substituteParameters(intermediateCode: IILCode, parameters: FunctionCallParameters): IILCode { + const parameterNames = intermediateCode.getUniqueParameterNames(); if (parameterNames.length && !parameters) { throw new Error(`no parameters defined, expected: ${printList(parameterNames)}`); } for (const parameterName of parameterNames) { const parameterValue = parameters[parameterName]; - intermediateCode = substituteParameter(intermediateCode, parameterName, parameterValue); + if (!parameterValue) { + throw Error(`parameter value is not provided for "${parameterName}" in function call`); + } + intermediateCode = intermediateCode.substituteParameter(parameterName, parameterValue); } return intermediateCode; } @@ -158,43 +161,3 @@ function getCallSequence(call: ScriptFunctionCall): FunctionCall[] { } return [ call as FunctionCall ]; } - -function getDistinctValues(values: readonly string[]): string[] { - return values.filter((value, index, self) => { - return self.indexOf(value) === index; - }); -} - -// Trim each expression and put them inside "{{exp|}}" e.g. "{{ $hello }}" becomes "{{exp|$hello}}" -function compileToIL(code: string) { - return code.replace(/\{\{([\s]*[^;\s\{]+[\s]*)\}\}/g, (_, match) => { - return `\{\{exp|${match.trim()}\}\}`; - }); -} - -// Parses all distinct usages of {{exp|$parameterName}} -function getUniqueParameterNamesFromIL(ilCode: string) { - const allSubstitutions = ilCode.matchAll(/\{\{exp\|\$([^;\s\{]+[\s]*)\}\}/g); - const allParameters = Array.from(allSubstitutions, (match) => match[1]); - const uniqueParameterNames = getDistinctValues(allParameters); - return uniqueParameterNames; -} - -// substitutes {{exp|$parameterName}} to value of the parameter -function substituteParameter(ilCode: string, parameterName: string, parameterValue: string) { - if (!parameterValue) { - throw Error(`parameter value is not provided for "${parameterName}" in function call`); - } - const pattern = `{{exp|$${parameterName}}}`; - return ilCode.split(pattern).join(parameterValue); // as .replaceAll() is not yet supported by TS -} - -// finds all "{{exp|..}} left" -function ensureNoExpressionLeft(ilCode: string) { - const allSubstitutions = ilCode.matchAll(/\{\{exp\|(.*?)\}\}/g); - const allMatches = Array.from(allSubstitutions, (match) => match[1]); - const uniqueExpressions = getDistinctValues(allMatches); - if (uniqueExpressions.length > 0) { - throw new Error(`unknown expression: ${printList(uniqueExpressions)}`); - } -} diff --git a/src/application/Parser/ProjectInformationParser.ts b/src/application/Parser/ProjectInformationParser.ts new file mode 100644 index 00000000..78110f43 --- /dev/null +++ b/src/application/Parser/ProjectInformationParser.ts @@ -0,0 +1,12 @@ +import { IProjectInformation } from '@/domain/IProjectInformation'; +import { ProjectInformation } from '@/domain/ProjectInformation'; + +export function parseProjectInformation( + environment: NodeJS.ProcessEnv): IProjectInformation { + return new ProjectInformation( + environment.VUE_APP_NAME, + environment.VUE_APP_VERSION, + environment.VUE_APP_REPOSITORY_URL, + environment.VUE_APP_HOMEPAGE_URL, + ); +} diff --git a/src/application/Parser/ScriptParser.ts b/src/application/Parser/ScriptParser.ts index 6b96f48c..d1ffc38b 100644 --- a/src/application/Parser/ScriptParser.ts +++ b/src/application/Parser/ScriptParser.ts @@ -1,37 +1,32 @@ import { Script } from '@/domain/Script'; import { YamlScript } from 'js-yaml-loader!./application.yaml'; import { parseDocUrls } from './DocumentationParser'; -import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel'; +import { RecommendationLevel } from '@/domain/RecommendationLevel'; import { IScriptCompiler } from './Compiler/IScriptCompiler'; import { IScriptCode } from '@/domain/IScriptCode'; import { ScriptCode } from '@/domain/ScriptCode'; +import { createEnumParser, IEnumParser } from '../Common/Enum'; -export function parseScript(yamlScript: YamlScript, compiler: IScriptCompiler): Script { +export function parseScript( + yamlScript: YamlScript, compiler: IScriptCompiler, + levelParser = createEnumParser(RecommendationLevel)): Script { validateScript(yamlScript); if (!compiler) { throw new Error('undefined compiler'); } const script = new Script( - /* name */ yamlScript.name, - /* code */ parseCode(yamlScript, compiler), - /* docs */ parseDocUrls(yamlScript), - /* level */ getLevel(yamlScript.recommend)); + /* name */ yamlScript.name, + /* code */ parseCode(yamlScript, compiler), + /* docs */ parseDocUrls(yamlScript), + /* level */ parseLevel(yamlScript.recommend, levelParser)); return script; } -function getLevel(level: string): RecommendationLevel | undefined { +function parseLevel(level: string, parser: IEnumParser): RecommendationLevel | undefined { if (!level) { return undefined; } - if (typeof level !== 'string') { - throw new Error(`level must be a string but it was ${typeof level}`); - } - const typedLevel = RecommendationLevelNames - .find((l) => l.toLowerCase() === level.toLowerCase()); - if (!typedLevel) { - throw new Error(`unknown level: \"${level}\"`); - } - return RecommendationLevel[typedLevel as keyof typeof RecommendationLevel]; + return parser.parseEnum(level, 'level'); } function parseCode(yamlScript: YamlScript, compiler: IScriptCompiler): IScriptCode { diff --git a/src/application/Parser/ScriptingDefinitionParser.ts b/src/application/Parser/ScriptingDefinitionParser.ts new file mode 100644 index 00000000..c303fc20 --- /dev/null +++ b/src/application/Parser/ScriptingDefinitionParser.ts @@ -0,0 +1,36 @@ +import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; +import { YamlScriptingDefinition } from 'js-yaml-loader!./application.yaml'; +import { ScriptingDefinition } from '@/domain/ScriptingDefinition'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { IProjectInformation } from '@/domain/IProjectInformation'; +import { createEnumParser } from '../Common/Enum'; +import { generateIlCode } from './Compiler/ILCode'; + +export function parseScriptingDefinition( + definition: YamlScriptingDefinition, + info: IProjectInformation, + date = new Date(), + languageParser = createEnumParser(ScriptingLanguage)): IScriptingDefinition { + if (!info) { + throw new Error('undefined info'); + } + if (!definition) { + throw new Error('undefined definition'); + } + const language = languageParser.parseEnum(definition.language, 'language'); + const startCode = applySubstitutions(definition.startCode, info, date); + const endCode = applySubstitutions(definition.endCode, info, date); + return new ScriptingDefinition( + language, + startCode, + endCode, + ); +} + +function applySubstitutions(code: string, info: IProjectInformation, date: Date): string { + let ilCode = generateIlCode(code); + ilCode = ilCode.substituteParameter('homepage', info.homepage); + ilCode = ilCode.substituteParameter('version', info.version); + ilCode = ilCode.substituteParameter('date', date.toUTCString()); + return ilCode.compile(); +} diff --git a/src/application/State/ApplicationContext.ts b/src/application/State/ApplicationContext.ts new file mode 100644 index 00000000..1fd26e59 --- /dev/null +++ b/src/application/State/ApplicationContext.ts @@ -0,0 +1,20 @@ +import { IApplicationContext } from './IApplicationContext'; +import { IApplication } from '@/domain/IApplication'; +import { IApplicationState } from './IApplicationState'; +import { ApplicationState } from './ApplicationState'; +import applicationFile from 'js-yaml-loader!@/application/application.yaml'; +import { parseApplication } from '../Parser/ApplicationParser'; + + +export function createContext(): IApplicationContext { + const application = parseApplication(applicationFile); + const context = new ApplicationContext(application); + return context; +} + +export class ApplicationContext implements IApplicationContext { + public readonly state: IApplicationState; + public constructor(public readonly app: IApplication) { + this.state = new ApplicationState(app); + } +} diff --git a/src/application/State/ApplicationContextProvider.ts b/src/application/State/ApplicationContextProvider.ts new file mode 100644 index 00000000..a0482da4 --- /dev/null +++ b/src/application/State/ApplicationContextProvider.ts @@ -0,0 +1,9 @@ +import { ApplicationContext } from './ApplicationContext'; +import { IApplicationContext } from '@/application/State/IApplicationContext'; +import applicationFile from 'js-yaml-loader!@/application/application.yaml'; +import { parseApplication } from '@/application/Parser/ApplicationParser'; + +export function buildContext(): IApplicationContext { + const application = parseApplication(applicationFile); + return new ApplicationContext(application); +} diff --git a/src/application/State/ApplicationState.ts b/src/application/State/ApplicationState.ts index be9207fc..30288239 100644 --- a/src/application/State/ApplicationState.ts +++ b/src/application/State/ApplicationState.ts @@ -3,43 +3,18 @@ import { IUserFilter } from './Filter/IUserFilter'; import { ApplicationCode } from './Code/ApplicationCode'; import { UserSelection } from './Selection/UserSelection'; import { IUserSelection } from './Selection/IUserSelection'; -import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy'; -import { Signal } from '@/infrastructure/Events/Signal'; -import { parseApplication } from '../Parser/ApplicationParser'; import { IApplicationState } from './IApplicationState'; -import { Script } from '@/domain/Script'; import { IApplication } from '@/domain/IApplication'; import { IApplicationCode } from './Code/IApplicationCode'; -import applicationFile from 'js-yaml-loader!@/application/application.yaml'; -import { SelectedScript } from '@/application/State/Selection/SelectedScript'; -/** Mutatable singleton application state that's the single source of truth throughout the application */ export class ApplicationState implements IApplicationState { - /** Get singleton application state */ - public static GetAsync(): Promise { - return ApplicationState.instance.getValueAsync(); - } - - /** Application instance with all scripts. */ - private static instance = new AsyncLazy(() => { - const application = parseApplication(applicationFile); - const selectedScripts = new Array