diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 07887d5..bf70e79 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -29,7 +29,7 @@ jobs: run: dart pub get - name: Generate docs - run: dartdoc + run: dart doc - name: Extract branch name shell: bash diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 7e69fd3..0cc1056 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -88,8 +88,11 @@ jobs: - name: Unit tests run: dart run test --coverage="coverage" test/unit/** + - name: Integration tests + run: dart run test --coverage="coverage" test/integration/** + - name: Format coverage - run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/coverage.lcov --packages=.packages --report-on=lib + run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/coverage.lcov --packages=.dart_tool/package_config.json --report-on=lib - name: Generate coverage run: genhtml coverage/coverage.lcov -o coverage/coverage_gen diff --git a/.gitignore b/.gitignore index 491d13f..720cb23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,16 @@ # Files and directories created by pub. .dart_tool/ .packages +pubspec.lock # Conventional directory for build output. build/ +# Generated files +*.g.dart +*.exe + # Local doc/ .vscode/ - -pubspec.lock - .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4363a45..e10d947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,35 @@ +## 4.2.0 +__New features__: +- Added a script which allows `nyxx_commands` to be compiled. For more information, run `dart pub global activate nyxx_commands` and `nyxx-compile --help`. +- Implemented support for permissions V2. See `PermissionsCheck` for more. + +__Deprecaations__: +- Deprecated `AbstractCheck.permissions` and all associated features. + +## 4.2.0-dev.1 +__New features__: +- Added a script which allows `nyxx_commands` to be compiled. For more information, run `dart pub global activate nyxx_commands` and `nyxx-compile --help`. + +## 4.2.0-dev.0 +__Deprecations__: +- Deprecated `AbstractCheck.permissions` and all associated features. + +__New features__: +- Added `AbtractCheck.allowsDm` and `AbstractCheck.requiredPermissions` for integrating checks with permissions v2. +- Updated `Check.deny`, `Check.any` and `Check.all` to work with permissions v2. +- Added `PermissionsCheck`, for checking if users have a specific permission. + +__Miscellaneous__: +- Bump `nyxx_interactions` to 4.2.0. +- Added proper names to context type checks if none is provided. + ## 4.1.2 __Bug fixes__: - Fixes an issue where slash commands nested within text-only commands would not be registered ## 4.1.1 __Bug fixes__: -- Correctly export the `@AUtocomplete(...)` annotation. +- Correctly export the `@Autocomplete(...)` annotation. ## 4.1.0 __New features__: diff --git a/analysis_options.yaml b/analysis_options.yaml index 9a84263..d6cb7dc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -6,7 +6,7 @@ linter: implementation_imports: false analyzer: - exclude: [build/**] + exclude: [build/**, "*.g.dart"] language: strict-raw-types: true strong-mode: diff --git a/bin/compile.dart b/bin/compile.dart new file mode 100644 index 0000000..683b026 --- /dev/null +++ b/bin/compile.dart @@ -0,0 +1,121 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:logging/logging.dart'; + +import 'compile/generator.dart'; + +void main(List args) async { + late ArgParser parser; + parser = ArgParser() + ..addFlag( + 'help', + abbr: 'h', + negatable: false, + help: 'Print this help and exit', + ) + ..addOption( + 'output', + abbr: 'o', + defaultsTo: 'out.g.dart', + help: 'The file where generated output should be written to', + ) + ..addOption( + 'verbosity', + abbr: 'v', + defaultsTo: 'info', + allowed: Level.LEVELS.map((e) => e.name.toLowerCase()), + help: 'Change the verbosity level of the command-line output', + ) + ..addFlag( + 'compile', + abbr: 'c', + defaultsTo: true, + help: 'Compile the generated file with `dart compile exe`', + ) + ..addFlag( + 'format', + abbr: 'f', + defaultsTo: true, + help: 'Format the generated output before compiling', + ) + ..addFlag( + 'complete', + defaultsTo: false, + help: 'Generate metadata for all the files in your program, instead of just the necessary' + ' ones. This can help in cases where your program does not run when using the normal' + ' compiler.', + ); + + if (args.isEmpty) { + printHelp(parser); + return; + } + + ArgResults result = parser.parse(args); + + // Help + + if (result['help'] as bool) { + printHelp(parser); + return; + } + + // Logging + + Logger.root.level = Level.LEVELS.firstWhere( + (element) => element.name.toLowerCase() == result['verbosity'], + ); + Logger.root.onRecord.listen((LogRecord rec) { + print("[${rec.time}] [${rec.level.name}] ${rec.message}"); + }); + + // Generation + + await generate( + result.rest.first, + result['output'] as String, + result['format'] as bool, + result['complete'] as bool, + ); + + // Compilation + + if (result['compile'] as bool) { + logger.info('Compiling file to executable'); + + Process compiler = await Process.start('dart', ['compile', 'exe', result['output'] as String]); + + compiler.stdout.transform(utf8.decoder).listen(stdout.write); + compiler.stderr.transform(utf8.decoder).listen(stderr.write); + } +} + +void printHelp(ArgParser p) { + print( + ''' +Generate code from a Dart program that contains metadata about types and function metadata, and can +be compiled to run a program written with nyxx_commands. + +Usage: nyxx-compile [options] + +Options: +''', + ); + print(p.usage); +} diff --git a/bin/compile/element_tree_visitor.dart b/bin/compile/element_tree_visitor.dart new file mode 100644 index 0000000..0dd6f89 --- /dev/null +++ b/bin/compile/element_tree_visitor.dart @@ -0,0 +1,134 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:analyzer/dart/analysis/analysis_context.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:nyxx_commands/src/errors.dart'; + +import 'generator.dart'; + +/// An AST visitor that checks every file in the entire program, following imports, exports and part +/// directives. Files that are deemed "interesting" are visited in full by this visitor. +/// +/// Files are deemed "interesting" if: +/// - The file is part of `package:nyxx_commands` +/// - The file imports an "interesting" file +class EntireAstVisitor extends RecursiveAstVisitor { + static final Map _cache = {}; + final List _interestingSources = []; + + final AnalysisContext context; + final bool slow; + + EntireAstVisitor(this.context, this.slow); + + /// Makes this visitor check all the imported, exported or "part-ed" files in [element], visiting + /// ones that are deemed "interesting". + Future visitLibrary(LibraryElement element) async { + List visited = []; + + void recursivelyGatherSources(LibraryElement element) { + String source = element.source.fullName; + + logger.finest('Checking source "$source"'); + + if (visited.contains(source)) { + return; + } + + visited.add(source); + + if (isLibraryInteresting(element)) { + _interestingSources.add(source); + } + + for (final library in [...element.importedLibraries, ...element.exportedLibraries]) { + recursivelyGatherSources(library); + } + } + + recursivelyGatherSources(element); + + while (_interestingSources.isNotEmpty) { + List interestingSources = _interestingSources.sublist(0); + _interestingSources.clear(); + + logger.fine('Visiting interesting sources $interestingSources'); + + await Future.wait(interestingSources.map(visitUnit)); + } + } + + final List _checkingLibraries = []; + static final Map _interestingCache = {}; + + /// Returns whether a given library is "interesting" + bool isLibraryInteresting(LibraryElement element) { + if (slow) { + return true; + } + + if (_interestingCache.containsKey(element)) { + return _interestingCache[element]!; + } + + if (_checkingLibraries.contains(element)) { + return false; + } + + bool ret; + + _checkingLibraries.add(element); + + if (element.identifier.startsWith('package:nyxx_commands')) { + ret = true; + } else { + ret = element.importedLibraries.any((library) => isLibraryInteresting(library)) || + element.exportedLibraries.any((library) => isLibraryInteresting(library)); + } + + _checkingLibraries.removeLast(); + + return _interestingCache[element] = ret; + } + + /// Makes this visitor get the full AST for a given source and visit it. + Future visitUnit(String source) async { + logger.finer('Getting AST for source "$source"'); + + SomeResolvedUnitResult result = + _cache[source] ??= await context.currentSession.getResolvedUnit(source); + + if (result is! ResolvedUnitResult) { + throw CommandsError('Got invalid analysis result for source $source'); + } + + logger.finer('Got AST for source "$source"'); + + result.unit.accept(this); + } + + @override + void visitPartDirective(PartDirective directive) { + super.visitPartDirective(directive); + + // Visit "part-ed" files of interesting sources + _interestingSources.add(directive.uriSource!.fullName); + } +} diff --git a/bin/compile/function_metadata/compile_time_function_data.dart b/bin/compile/function_metadata/compile_time_function_data.dart new file mode 100644 index 0000000..c129cea --- /dev/null +++ b/bin/compile/function_metadata/compile_time_function_data.dart @@ -0,0 +1,80 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/type.dart'; + +/// Metadata about a function. +class CompileTimeFunctionData { + /// The id that was associated with this function in the [id] invocation. + final Expression id; + + /// The parameter data for this function. + final List parametersData; + + const CompileTimeFunctionData(this.id, this.parametersData); + + @override + String toString() => 'CompileTimeFunctionData[id=$id, parameters=$parametersData]'; +} + +/// Metadata about a function parameter. +class CompileTimeParameterData { + /// The name of this parameter. + final String name; + + /// The type of this parameter. + final DartType type; + + /// Whether this parameter is optional. + final bool isOptional; + + // We don't care about named parameters because they aren't allowed in command callbacks. + + /// The description of this parameter. + final String? description; + + /// The default value of this parameter. + final Expression? defaultValue; + + /// The choices for this parameter. + final Expression? choices; + + /// The converter override for this parameter. + final Annotation? converterOverride; + + /// The autocompletion handler override for this parameter. + final Annotation? autocompleteOverride; + + const CompileTimeParameterData( + this.name, + this.type, + this.isOptional, + this.description, + this.defaultValue, + this.choices, + this.converterOverride, + this.autocompleteOverride, + ); + + @override + String toString() => 'CompileTimeParameterData[name=$name, ' + 'type=${type.getDisplayString(withNullability: true)}, ' + 'isOptional=$isOptional, ' + 'description=$description, ' + 'defaultValue=$defaultValue, ' + 'choices=$choices, ' + 'converterOverride=$converterOverride,' + 'autocompleteOverride=$autocompleteOverride]'; +} diff --git a/bin/compile/function_metadata/metadata_builder.dart b/bin/compile/function_metadata/metadata_builder.dart new file mode 100644 index 0000000..6233a16 --- /dev/null +++ b/bin/compile/function_metadata/metadata_builder.dart @@ -0,0 +1,180 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/constant/value.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/src/dart/element/element.dart'; +import 'package:nyxx_commands/nyxx_commands.dart'; + +import '../generator.dart'; +import '../type_tree/tree_builder.dart'; +import 'compile_time_function_data.dart'; + +/// Convert [idCreations] into function metadata. +Iterable getFunctionData( + Iterable ids, +) { + List result = []; + + outerLoop: + for (final id in ids) { + if (id.argumentList.arguments.length != 2) { + logger.shout( + 'Unexpected number of arguments ${id.argumentList.arguments.length} in id invocation'); + continue; + } + + if (id.argumentList.arguments[1] is! FunctionExpression) { + throw CommandsError('Functions passed to the `id` function must be function literals'); + } + + FormalParameterList parameterList = + (id.argumentList.arguments[1] as FunctionExpression).parameters!; + + List parameterData = [ + // The context parameter + CompileTimeParameterData( + parameterList.parameterElements.first!.name, + parameterList.parameterElements.first!.type, + false, + null, + null, + null, + null, + null, + ) + ]; + + for (final parameter in parameterList.parameters.skip(1)) { + if (parameter.identifier == null) { + // Parameters must have a name to be used. Skip this function. + continue outerLoop; + } + + /// Extracts all the annotations on a parameter that have a type with the type id [type]. + Iterable annotationsWithType(int type) { + Iterable constructorAnnotations = parameter.metadata + .where((node) => node.elementAnnotation?.element is ConstructorElement) + .where((node) => + getId((node.elementAnnotation!.element as ConstructorElement) + .enclosingElement + .thisType) == + type); + + Iterable constVariableAnnotations = parameter.metadata + .where((node) => (node.elementAnnotation?.element is ConstVariableElement)) + .where((node) => + getId((node.elementAnnotation!.element as ConstVariableElement) + .evaluationResult! + .value! + .type) == + type); + + return constructorAnnotations.followedBy(constVariableAnnotations); + } + + Iterable nameAnnotations = annotationsWithType(nameId); + + Iterable descriptionAnnotations = annotationsWithType(descriptionId); + + Iterable choicesAnnotations = annotationsWithType(choicesId); + + Iterable useConverterAnnotations = annotationsWithType(useConverterId); + + Iterable autocompleteAnnotations = annotationsWithType(autocompleteId); + + if ([ + nameAnnotations, + descriptionAnnotations, + choicesAnnotations, + useConverterAnnotations, + autocompleteAnnotations, + ].any((annotations) => annotations.length > 1)) { + throw CommandsError( + 'Cannot have more than 1 of each of @Name, @Description, @Choices,' + ' @UseConverter or @Autocomplete per parameter', + ); + } + + String name; + String? description; + Expression? choices; + Expression? defaultValue; + Annotation? converterOverride; + Annotation? autocompleteOverride; + + if (nameAnnotations.isNotEmpty) { + name = getAnnotationData(nameAnnotations.first.elementAnnotation!) + .getField('name')! + .toStringValue()!; + } else { + name = parameter.identifier!.name; + } + + if (descriptionAnnotations.isNotEmpty) { + description = getAnnotationData(descriptionAnnotations.first.elementAnnotation!) + .getField('value')! + .toStringValue()!; + } + + if (choicesAnnotations.isNotEmpty) { + choices = choicesAnnotations.first.arguments!.arguments.first; + } + + if (parameter is DefaultFormalParameter) { + defaultValue = parameter.defaultValue; + } + + if (useConverterAnnotations.isNotEmpty) { + converterOverride = useConverterAnnotations.first; + } + + if (autocompleteAnnotations.isNotEmpty) { + autocompleteOverride = autocompleteAnnotations.first; + } + + parameterData.add(CompileTimeParameterData( + name, + parameter.declaredElement!.type, + parameter.isOptional, + description, + defaultValue, + choices, + converterOverride, + autocompleteOverride, + )); + } + + result.add(CompileTimeFunctionData(id.argumentList.arguments.first, parameterData)); + } + + return result; +} + +/// Extract the object referenced or creatted by an annotation. +DartObject getAnnotationData(ElementAnnotation annotation) { + DartObject? result; + if (annotation.element is ConstructorElement) { + result = annotation.computeConstantValue(); + } else if (annotation.element is ConstVariableElement) { + result = (annotation.element as ConstVariableElement).computeConstantValue(); + } + + if (result == null) { + throw CommandsError('Could not evaluate $annotation'); + } + + return result; +} diff --git a/bin/compile/function_metadata/metadata_builder_visitor.dart b/bin/compile/function_metadata/metadata_builder_visitor.dart new file mode 100644 index 0000000..bc868b7 --- /dev/null +++ b/bin/compile/function_metadata/metadata_builder_visitor.dart @@ -0,0 +1,38 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/analysis/analysis_context.dart'; +import 'package:analyzer/dart/ast/ast.dart'; + +import '../element_tree_visitor.dart'; + +/// An AST visitor that collects all instances of [id] invocations. +class FunctionBuilderVisitor extends EntireAstVisitor { + final List ids = []; + + FunctionBuilderVisitor(AnalysisContext context, bool slow) : super(context, slow); + + @override + void visitMethodInvocation(MethodInvocation node) { + super.visitMethodInvocation(node); + + Expression function = node.function; + + if (function is Identifier && + function.staticElement?.location?.encoding == + 'package:nyxx_commands/src/util/util.dart;package:nyxx_commands/src/util/util.dart;id') { + ids.add(node); + } + } +} diff --git a/bin/compile/generator.dart b/bin/compile/generator.dart new file mode 100644 index 0000000..965c460 --- /dev/null +++ b/bin/compile/generator.dart @@ -0,0 +1,453 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context.dart'; +import 'package:analyzer/dart/analysis/context_builder.dart'; +import 'package:analyzer/dart/analysis/context_locator.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:logging/logging.dart'; +import 'package:nyxx_commands/src/errors.dart'; +import 'package:path/path.dart'; + +import 'function_metadata/compile_time_function_data.dart'; +import 'function_metadata/metadata_builder_visitor.dart'; +import 'function_metadata/metadata_builder.dart'; +import 'to_source.dart'; +import 'type_tree/type_builder_visitor.dart'; +import 'type_tree/tree_builder.dart'; +import 'type_tree/type_data.dart'; + +final Logger logger = Logger('Commands Compiler'); + +/// Generates the metadata for the program located at [path], writing the output to the file at +/// [outPath]. +/// +/// If [formatOutput] is `true`, the resulting file will be formatted with `dart format`. +Future generate(String path, String outPath, bool formatOutput, bool slow) async { + path = normalize(absolute(path)); + + logger.info('Analyzing file "$path"'); + + final ContextLocator locator = ContextLocator(); + final ContextBuilder builder = ContextBuilder(); + + final AnalysisContext context = + builder.createContext(contextRoot: locator.locateRoots(includedPaths: [path]).first); + + final SomeResolvedLibraryResult result = await context.currentSession.getResolvedLibrary(path); + + logger.info('Finished analyzing file "$path"'); + + if (result is! ResolvedLibraryResult) { + logger.shout('Did not get a valid analysis result for "$path"'); + throw CommandsException('Did not get a valid analysis result for "$path"'); + } + + // Require our program to have a `main()` function so we can call it + if (result.element.entryPoint == null) { + logger.shout('No entry point was found for file "$path"'); + throw CommandsException('No entry point was found for file "$path"'); + } + + Map typeTree = await processTypes(result.element, context, slow); + + Iterable functions = + await processFunctions(result.element, context, slow); + + String output = generateOutput( + {...typeTree.values}, + functions, + result.element.source.uri.toString(), + result.element.entryPoint!.parameters.isNotEmpty, + formatOutput, + ); + + logger.info('Writing output to file "$outPath"'); + + await File(outPath).writeAsString(output); + + logger.finest('Done'); +} + +/// Generates type metadata for [result] and all child units (includes imports, exports and parts). +Future> processTypes( + LibraryElement result, + AnalysisContext context, + bool slow, +) async { + logger.info('Building type tree from AST'); + + final TypeBuilderVisitor typeBuilder = TypeBuilderVisitor(context, slow); + + await typeBuilder.visitLibrary(result); + + logger.fine('Found ${typeBuilder.types.length} type instances'); + + final Map typeTree = buildTree(typeBuilder.types); + + logger.info('Finished building type tree with ${typeTree.length} entries'); + + return typeTree; +} + +/// Generates function metadata for all creations of [id] invocations in [result] and child units. +Future> processFunctions( + LibraryElement result, + AnalysisContext context, + bool slow, +) async { + logger.info('Loading function metadata'); + + final FunctionBuilderVisitor functionBuilder = FunctionBuilderVisitor(context, slow); + + await functionBuilder.visitLibrary(result); + + logger.fine('Found ${functionBuilder.ids.length} function instances'); + + final Iterable data = getFunctionData(functionBuilder.ids); + + logger.info('Got data for ${data.length} functions'); + + return data; +} + +/// Generates the content that should be written to the output file from type and function metadata. +/// +/// The resulting file will have an entrypoint that loads the program metadata, and then calls the +/// entrypoint in the file [pathToMainFile]. +/// +/// If [hasArgsArgument] is set, the entrypoint will be called with the command-line arguments. +/// +/// If [formatOutput] is set, the generated content will be passed through `dart format` before +/// being returned. +String generateOutput( + Iterable typeTree, + Iterable functionData, + String pathToMainFile, + bool hasArgsArgument, + bool formatOutput, +) { + logger.info('Generating output'); + + // The base stub: + // - Imports the nyxx_commands runtime type data classes so we can instanciate them + // - Imports the specified program entrypoint so we can call it later + // - Imports `dart:core` so we don't remove it from the global scope by importing it with an alias + // - Adds a warning comment to the top of the file + StringBuffer result = StringBuffer(''' + import 'package:nyxx_commands/src/mirror_utils/mirror_utils.dart'; + import '$pathToMainFile' as _main show main; + import "dart:core"; + + // Auto-generated file + // DO NOT EDIT + + // Type data + + '''); + + // Import directives that will be placed at the start of the file + // Other steps in the generation process can add items to this set in order to import types from + // other files + Set imports = {}; + + typeTree = typeTree.toSet(); + + writeTypeMetadata(typeTree, result); + + result.write(''' + + // Nullable typedefs + + '''); + + Set successfulIds = writeTypeDefs(typeTree, result, imports); + + result.write(''' + + // Type mappings + + '''); + + writeTypeMappings(successfulIds, result); + + result.write(''' + + // Function data + + '''); + + writeFunctionData(functionData, result, imports, successfulIds); + + result.write(''' + + // Main function wrapper + void main(List args) { + loadData(typeTree, typeMappings, functionData); + + _main.main(${hasArgsArgument ? 'args' : ''}); + } + '''); + + logger.fine('Formatting output'); + + result = StringBuffer(imports.join('\n'))..write(result.toString()); + + if (!formatOutput) { + return result.toString(); + } + + return DartFormatter(lineEnding: '\n').format(result.toString()); +} + +/// Generates the content that represents the type metadata of a program from [typeTree] and writes +/// it to [result]. +void writeTypeMetadata(Iterable typeTree, StringBuffer result) { + result.write('const Map typeTree = {'); + + // Special types + result.write('0: DynamicTypeData(),'); + result.write('1: VoidTypeData(),'); + result.write('2: NeverTypeData(),'); + + // Other types + for (final type in typeTree.whereType()) { + result.write(''' + ${type.id}: InterfaceTypeData( + name: r"${type.name}", + id: ${type.id}, + strippedId: ${type.strippedId}, + superClasses: [${type.superClasses.join(',')}], + typeArguments: [${type.typeArguments.join(',')}], + isNullable: ${type.isNullable}, + ), + '''); + } + + for (final type in typeTree.whereType()) { + result.write(''' + ${type.id}: FunctionTypeData( + name: r"${type.name}", + id: ${type.id}, + returnType: ${type.returnType}, + positionalParameterTypes: [${type.positionalParameterTypes.join(',')}], + requiredPositionalParametersCount: ${type.requiredPositionalParametersCount}, + requiredNamedParametersType: {${type.requiredNamedParametersType.entries.map((entry) => 'r"${entry.key}": ${entry.value}').join(',')}}, + optionalNamedParametersType: {${type.optionalNamedParametersType.entries.map((entry) => 'r"${entry.key}": ${entry.value}').join(',')}}, + isNullable: ${type.isNullable}, + ), + '''); + } + + result.write('};'); +} + +/// Generates a set of `typedef` statements that can be used as keys in maps to represent types, and +/// writes them to [result]. +/// +/// Imports needed to create the typedefs are added to [imports]. +/// +/// This method is needed as nullable type literals are interpreted as ternary statements in some +/// cases, and can lead to errors. Creating a typedef that reflects the same type and using that +/// instead of a type literal avoids this issue. +Set writeTypeDefs(Iterable typeTree, StringBuffer result, Set imports) { + Set successfulIds = {}; + + for (final type in typeTree) { + if (type is DynamicTypeData || type is VoidTypeData || type is NeverTypeData) { + result.write('typedef t_${type.id} = '); + + if (type is DynamicTypeData) { + result.write('dynamic'); + } else if (type is VoidTypeData) { + result.write('void'); + } else { + result.write('Never'); + } + + result.write(';'); + + successfulIds.add(type.id); + continue; + } + + List? typeSourceRepresentation = toTypeSource(type.source); + + if (typeSourceRepresentation == null) { + logger.fine('Excluding type $type as data for type was not resolved'); + continue; + } + + successfulIds.add(type.id); + + imports.addAll(typeSourceRepresentation.skip(2)); + + result.write( + 'typedef t_${type.id}${typeSourceRepresentation.first} = ${typeSourceRepresentation[1]};\n'); + } + + return successfulIds; +} + +/// Generates a map literal that maps runtime [Type] instances to an ID that can be used to look up +/// their metadata, and writes the result to [result]. +void writeTypeMappings(Iterable ids, StringBuffer result) { + result.write('const Map typeMappings = {'); + + for (final id in ids) { + result.write('t_$id: $id,'); + } + + result.write('};'); +} + +/// Generates a map literal that maps [id] ids to function metadata that can be used to look up +/// function metadata at runtime, and writes the result to [result]. +/// +/// Imports needed to write the metadata will be added to [imports]. +/// +/// [loadedTypeIds] must be a set of type metadata IDs that are available to use. +void writeFunctionData( + Iterable functionData, + StringBuffer result, + Set imports, + Set loadedTypeIds, +) { + Set loadedIds = {}; + + result.write('const Map functionData = {'); + + outerLoop: + for (final function in functionData) { + String parameterDataSource = ''; + + for (final parameter in function.parametersData) { + String? converterSource; + + if (parameter.converterOverride != null) { + List? converterOverrideData = toConverterSource(parameter.converterOverride!); + + if (converterOverrideData == null) { + logger.shout( + 'Unable to resolve converter override for parameter ${parameter.name}, skipping function', + ); + continue outerLoop; + } + + imports.addAll(converterOverrideData.skip(1)); + + converterSource = converterOverrideData.first; + } + + if (!loadedTypeIds.contains(getId(parameter.type))) { + logger.shout('Parameter ${parameter.name} has an unresolved type, skipping function'); + continue outerLoop; + } + + String? defaultValueSource; + + if (parameter.defaultValue != null) { + List? defaultValueData = toExpressionSource(parameter.defaultValue!); + + if (defaultValueData == null) { + logger.warning( + 'Unable to resolve default value for parameter ${parameter.name}, skipping function', + ); + continue outerLoop; + } + + imports.addAll(defaultValueData.skip(1)); + + defaultValueSource = defaultValueData.first; + } + + String? choicesSource; + + if (parameter.choices != null) { + List? choicesData = toExpressionSource(parameter.choices!); + + if (choicesData == null) { + logger.warning( + 'Unable to resolve choices for parameter ${parameter.name}, skipping function', + ); + continue outerLoop; + } + + imports.addAll(choicesData.skip(1)); + + choicesSource = choicesData.first; + } + + String? autocompleteSource; + + if (parameter.autocompleteOverride != null) { + List? autocompleteOverrideData = toConverterSource(parameter.autocompleteOverride!); + + if (autocompleteOverrideData == null) { + // Unresolved autocomplete functions are more severe than unresolved types as the only + // case where an autocomplete override is specified is when the @Autocomplete annotation + // is explicitly used + logger.shout( + 'Unable to resolve converter override for parameter ${parameter.name}, skipping function', + ); + continue outerLoop; + } + + imports.addAll(autocompleteOverrideData.skip(1)); + + autocompleteSource = autocompleteOverrideData.first; + } + + parameterDataSource += ''' + ParameterData( + name: "${parameter.name}", + type: t_${getId(parameter.type)}, + isOptional: ${parameter.isOptional}, + description: ${parameter.description == null ? 'null' : '"${parameter.description}"'}, + defaultValue: $defaultValueSource, + choices: $choicesSource, + converterOverride: $converterSource, + autocompleteOverride: $autocompleteSource, + ), + '''; + } + + List? idData = toExpressionSource(function.id); + + if (idData == null) { + logger.shout("Couldn't resolve id ${function.id}"); + continue; + } + + if (loadedIds.contains(idData.first)) { + throw CommandsException('Duplicate identifier for id: ${function.id}'); + } + + loadedIds.add(idData.first); + + imports.addAll(idData.skip(1)); + + result.write('${idData.first}: FunctionData(['); + + result.write(parameterDataSource); + + result.write(']),'); + } + + result.write('};'); +} diff --git a/bin/compile/to_source.dart b/bin/compile/to_source.dart new file mode 100644 index 0000000..45af18f --- /dev/null +++ b/bin/compile/to_source.dart @@ -0,0 +1,579 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:nyxx_commands/src/errors.dart'; +import 'package:analyzer/src/dart/element/element.dart'; + +/// Converts an import path to a valid Dart import prefix that uniquely represents the path. +String toImportPrefix(String importPath) => importPath + .replaceAll(':', '_') + .replaceAll('/', '__') + .replaceAll(r'\', '__') + .replaceAll('.', '___'); + +/// Converts a [DartType] to a representation of that same type in source code. +/// +/// The return type is a list containing the following: +/// - The first element is a type argument list that should be appended to the name of the typedef; +/// - The second element is the representation of [type], relying on the type arguments from the +/// first element; +/// - The remaining elements are import statements used in the type representations. +/// +/// Returns `null` on failure. +List? toTypeSource(DartType type, [bool handleTypeParameters = true]) { + String typeArguments = ''; + + /// Gathers all the type parameters used in [type]. + /// + /// Elements in [noHandle] will not be processed. + Iterable recursivelyGatherTypeParameters( + DartType type, [ + Iterable noHandle = const [], + ]) sync* { + if (type is TypeParameterType) { + yield type; + if (!noHandle.contains(type)) { + yield* recursivelyGatherTypeParameters(type.bound, [type, ...noHandle]); + } + } else if (type is ParameterizedType) { + for (final typeArgument in type.typeArguments) { + yield* recursivelyGatherTypeParameters(typeArgument, noHandle); + } + } else if (type is FunctionType) { + yield* recursivelyGatherTypeParameters(type.returnType, noHandle); + + for (final parameterType in type.parameters.map((e) => e.type)) { + yield* recursivelyGatherTypeParameters(parameterType, noHandle); + } + } + } + + if (handleTypeParameters) { + // Get all the type parameters in [type]. + List typeParameters = recursivelyGatherTypeParameters(type).fold( + [], + (previousValue, element) { + // Only include each type parameter once + if (!previousValue.any((t) => t.element == element.element)) { + previousValue.add(element); + } + + return previousValue; + }, + ); + + if (typeParameters.isNotEmpty) { + // Create the type argument list if needed + typeArguments += '<'; + + for (final typeParameter in typeParameters) { + typeArguments += typeParameter.element.name; + + if (typeParameter.bound is! DynamicType) { + typeArguments += ' extends '; + + // We've already handled gathering *all* the type parameters in [type], so no need to + // handle them when converting the bounds to their own source representations, + List? data = toTypeSource(typeParameter.bound, false); + + if (data == null) { + return null; + } + + typeArguments += data[1]; + } + + if (typeParameter != typeParameters.last) { + typeArguments += ','; + } + } + + typeArguments += '>'; + } + } + + /// Get the actual name and imports from [type]. + List? getNameFor(DartType type) { + if (type is DynamicType) { + return ['dynamic']; + } else if (type is VoidType) { + return ['void']; + } else if (type is NeverType) { + return ['Never']; + } + + if (type.element?.library?.source.uri.toString().contains(':_') ?? false) { + return null; // Private or unresolved library; cannot handle + } else if (type.getDisplayString(withNullability: true).startsWith('_')) { + return null; // Private type; cannot handle + } + + String? importPrefix; + if (type.element is ClassElement) { + importPrefix = toImportPrefix(type.element!.library!.source.uri.toString()); + } + + List imports = []; + if (importPrefix != null) { + imports.add('import "${type.element!.library!.source.uri.toString()}" as $importPrefix;'); + } + + String prefix = importPrefix != null ? '$importPrefix.' : ''; + + String typeString; + + if (type is ParameterizedType) { + // Either a type parameter or a class + typeString = '$prefix${type.element!.name!}'; + + // Add type arguments as needed + if (type.typeArguments.isNotEmpty) { + typeString += '<'; + + for (final typeArgument in type.typeArguments) { + List? data = getNameFor(typeArgument); + + if (data == null) { + return null; + } + + typeString += data.first; + + imports.addAll(data.skip(1)); + + typeString += ','; + } + + typeString = typeString.substring(0, typeString.length - 1); // Remove last comma + + typeString += '>'; + } + } else if (type is FunctionType) { + // Function types are just composed of other types, so we handle this recursively + List? returnTypeData = getNameFor(type.returnType); + + if (returnTypeData == null) { + return null; + } + + imports.addAll(returnTypeData.skip(1)); + + typeString = returnTypeData.first; + + typeString += ' Function('; + + for (final parameterType in type.normalParameterTypes) { + List? parameterData = getNameFor(parameterType); + + if (parameterData == null) { + return null; + } + + imports.addAll(parameterData.skip(1)); + + typeString += parameterData.first; + + typeString += ','; + } + + if (type.optionalParameterTypes.isNotEmpty) { + typeString += '['; + + for (final optionalParameterType in type.optionalParameterTypes) { + List? parameterData = getNameFor(optionalParameterType); + + if (parameterData == null) { + return null; + } + + imports.addAll(parameterData.skip(1)); + + typeString += parameterData.first; + + typeString += ','; + } + + if (typeString.endsWith(',')) { + typeString = typeString.substring(0, typeString.length - 1); // Remove last comma + } + + typeString += ']'; + } + + if (type.namedParameterTypes.isNotEmpty) { + typeString += '{'; + + for (final entry in type.namedParameterTypes.entries) { + List? parameterData = getNameFor(entry.value); + + if (parameterData == null) { + return null; + } + + imports.addAll(parameterData.skip(1)); + + typeString += '${parameterData.first} ${entry.key}'; + + typeString += ','; + } + + if (typeString.endsWith(',')) { + typeString = typeString.substring(0, typeString.length - 1); // Remove last comma + } + + typeString += '}'; + } + + if (typeString.endsWith(',')) { + typeString = typeString.substring(0, typeString.length - 1); // Remove last comma + } + + typeString += ')'; + } else if (type is TypeParameterType) { + // Copy the name of the type parameter. It should have been introduced when we processed type + // arguments earlier, so we don't need to do anything more + typeString = type.element.name; + } else if (type is InterfaceType) { + // Just a simple class (or enum/mixin) + typeString = '$prefix${type.toString()}'; + } else { + throw CommandsError('Unknown type $type'); + } + + // Make it nullable if needed + if (type.nullabilitySuffix == NullabilitySuffix.question && !typeString.endsWith('?')) { + typeString += '?'; + } + + return [typeString, ...imports]; + } + + List? data = getNameFor(type); + + if (data == null) { + return null; + } + + return [typeArguments, ...data]; +} + +/// Converts an `@UseConverter` [Annotation] to a source code representation of the converter +/// specified. +List? toConverterSource(Annotation useConverterAnnotation) { + Expression argument = useConverterAnnotation.arguments!.arguments.first; + + return toExpressionSource(argument); +} + +/// Converts an [Expression] to a representation of that same expression in source code. +/// [expression] must be a valid constant or this method may fail. +/// +/// The return type is a list containing the following: +/// - The first element is the representation of [expression]; +/// - The remaining elements are import statements used in the expression. +/// +/// Returns `null` on failure. +List? toExpressionSource(Expression expression) { + // Simple types: Strings, integers, doubles, booleans, lists, maps + if (expression is StringLiteral) { + if (expression is SimpleStringLiteral) { + return [expression.literal.lexeme]; + } else if (expression is AdjacentStrings) { + return [expression.strings.map(toExpressionSource).join('')]; + } else { + throw CommandsError('Unsupported string literal type $expression'); + } + } else if (expression is IntegerLiteral) { + return [expression.literal.lexeme]; + } else if (expression is DoubleLiteral) { + return [expression.literal.lexeme]; + } else if (expression is BooleanLiteral) { + return [expression.literal.lexeme]; + } else if (expression is ListLiteral || expression is SetOrMapLiteral) { + // Lists and maps are very similar, so we reuse the same conversion for both + List imports = []; + + String openingBrace, closingBrace; + NodeList elements; + + if (expression is ListLiteral) { + openingBrace = '['; + closingBrace = ']'; + + elements = expression.elements; + } else if (expression is SetOrMapLiteral) { + openingBrace = '{'; + closingBrace = '}'; + + elements = expression.elements; + } else { + // Unreachable + assert(false); + return null; + } + + String ret = 'const $openingBrace'; + + for (final item in elements) { + // Convert each element to its source representation, then join them back together. + List? elementData = toCollectionElementSource(item); + + if (elementData == null) { + return null; + } + + imports.addAll(elementData.skip(1)); + + ret += elementData.first; + + ret += ','; + } + + ret += closingBrace; + + return [ + ret, + ...imports, + ]; + } else if (expression is Identifier) { + Element referenced = expression.staticElement!; + + if (referenced is PropertyAccessorElement) { + if (referenced.variable is TopLevelVariableElement) { + TopLevelVariableElement variable = referenced.variable as TopLevelVariableElement; + + if (variable.library.source.uri.toString().contains(':_')) { + return null; // Private library; cannot handle + } else if (!variable.isPublic) { + return null; // Private variable; cannot handle + } + + String importPrefix = toImportPrefix(variable.library.source.uri.toString()); + + return [ + '$importPrefix.${variable.name}', + 'import "${variable.library.source.uri.toString()}" as $importPrefix;', + ]; + } else if (referenced.variable is FieldElement) { + List? typeData = + toTypeSource((referenced.variable.enclosingElement as ClassElement).thisType); + + if (typeData == null || !referenced.variable.isPublic || !referenced.variable.isStatic) { + return null; + } + + if (typeData.first.isNotEmpty) { + // Can't handle type parameters + throw CommandsException('Cannot handle type parameters in expression toSource()'); + } + + return [ + '${typeData[1]}.${referenced.variable.name}', + ...typeData.skip(2), + ]; + } else { + throw CommandsError('Unhandled property accessor type ${referenced.variable.runtimeType}'); + } + } else if (referenced is FunctionElement) { + if (referenced.isPublic) { + String importPrefix = toImportPrefix(referenced.library.source.uri.toString()); + + if (referenced.library.source.uri.toString().contains(':_')) { + return null; // Private library; cannot handle + } + + return [ + '$importPrefix.${referenced.name}', + 'import "${referenced.library.source.uri.toString()}" as $importPrefix;', + ]; + } else { + return null; // Cannot handle private functions + } + } else if (referenced is MethodElement) { + List? typeData = toTypeSource((referenced.enclosingElement as ClassElement).thisType); + + if (typeData == null || !referenced.isPublic || !referenced.isStatic) { + return null; + } + + if (typeData.first.isNotEmpty) { + // Can't handle type parameters + throw CommandsException('Cannot handle type parameters in expression toSource()'); + } + + return [ + '${typeData[1]}.${referenced.name}', + ...typeData.skip(2), + ]; + } else if (referenced is ConstVariableElement) { + return toExpressionSource(referenced.constantInitializer!); + } + } else if (expression is InstanceCreationExpression) { + List? typeData = toTypeSource(expression.staticType!); + + if (typeData == null) { + return null; + } + + List imports = typeData.skip(2).toList(); + + if (typeData.first.isNotEmpty) { + // Can't handle type parameters + throw CommandsException('Cannot handle type parameters in toExpressionSource()'); + } + + String namedConstructor = ''; + if (expression.constructorName.name != null) { + namedConstructor = '.${expression.constructorName.name!.name}'; + } + + String result = '${typeData[1]}$namedConstructor('; + + for (final argument in expression.argumentList.arguments) { + List? argumentData = toExpressionSource(argument); + + if (argumentData == null) { + return null; + } + + imports.addAll(argumentData.skip(1)); + + result += '${argumentData.first},'; + } + + result += ')'; + + return [ + result, + ...imports, + ]; + } else if (expression is NamedExpression) { + List? wrappedExpressionData = toExpressionSource(expression.expression); + + if (wrappedExpressionData == null) { + return null; + } + + return [ + '${expression.name.label.name}: ${wrappedExpressionData.first}', + ...wrappedExpressionData.skip(1), + ]; + } else if (expression is PrefixExpression) { + List? expressionData = toExpressionSource(expression.operand); + + if (expressionData == null) { + return null; + } + + return [ + '${expression.operator.lexeme}${expressionData.first}', + ...expressionData.skip(1), + ]; + } else if (expression is BinaryExpression) { + List? leftData = toExpressionSource(expression.leftOperand); + List? rightData = toExpressionSource(expression.rightOperand); + + if (leftData == null || rightData == null) { + return null; + } + + return [ + '${leftData.first}${expression.operator.lexeme}${rightData.first}', + ...leftData.skip(1), + ...rightData.skip(1), + ]; + } + + throw CommandsError('Unhandled constant expression $expression'); +} + +/// Converts a [CollectionElement] to a representation of that same element in source code. +/// +/// The return type is a list containing the following: +/// - The first element is the representation of [item]; +/// - The remaining elements are import statements used in the expression. +/// +/// Returns `null` on failure. +List? toCollectionElementSource(CollectionElement item) { + if (item is Expression) { + // In most cases, [item] will just be another expression + return toExpressionSource(item); + } else if (item is IfElement) { + // Collection if statement + String ret = 'if('; + + List imports = []; + + List? conditionSource = toExpressionSource(item.condition); + + if (conditionSource == null) { + return null; + } + + imports.addAll(conditionSource.skip(1)); + + ret += conditionSource.first; + + ret += ') '; + + List? thenSource = toCollectionElementSource(item.thenElement); + + if (thenSource == null) { + return null; + } + + imports.addAll(thenSource.skip(1)); + + ret += thenSource.first; + + if (item.elseElement != null) { + ret += ' else '; + + List? elseSource = toCollectionElementSource(item.elseElement!); + + if (elseSource == null) { + return null; + } + + imports.addAll(elseSource.skip(1)); + + ret += elseSource.first; + } + + return [ + ret, + ...imports, + ]; + } else if (item is ForElement) { + // Collection for statement: disallowed beecause it is not const + throw CommandsException('Cannot reproduce for loops'); + } else if (item is MapLiteralEntry) { + // In the case we have a map, we need to convert both the key and the value + List? keyData = toExpressionSource(item.key); + List? valueData = toExpressionSource(item.value); + + if (keyData == null || valueData == null) { + return null; + } + + return [ + '${keyData.first}: ${valueData.first}', + ...keyData.skip(1), + ...valueData.skip(1), + ]; + } else if (item is SpreadElement) { + List? expressionData = toExpressionSource(item.expression); + + if (expressionData == null) { + return null; + } + + return [ + '...${item.isNullAware ? '?' : ''}${expressionData.first}', + ...expressionData.skip(1), + ]; + } else { + throw CommandsError('Unhandled type in collection literal: ${item.runtimeType}'); + } +} diff --git a/bin/compile/type_tree/tree_builder.dart b/bin/compile/type_tree/tree_builder.dart new file mode 100644 index 0000000..367b778 --- /dev/null +++ b/bin/compile/type_tree/tree_builder.dart @@ -0,0 +1,265 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:nyxx_commands/src/errors.dart'; + +import '../generator.dart'; +import 'type_data.dart'; + +final List processing = []; + +/// Returns an ID that uniquely represents [type]. +int getId(DartType type) { + if (processing.contains(type)) { + // Probably a type parameter with a bound that references itself + while (type is TypeParameterType) { + // Since we only care about the bound itself and not the name of the type parameter, we get + // rid of it here + type = type.bound; + } + + logger.finest('Recursive type found; returning hashCode of element ${type.element}'); + + return type.element.hashCode; + } + + processing.add(type); + + int ret; + + if (type is InterfaceType) { + if (type.isDartAsyncFutureOr && + [ + DynamicTypeData().id, + VoidTypeData().id, + NeverTypeData().id, + ].contains(getId(type.typeArguments.first))) { + ret = getId(type.typeArguments.first); + } else { + ret = Object.hashAll([type.hashCode, ...type.typeArguments.map(getId)]); + } + } else if (type is FunctionType) { + ret = Object.hashAll([getId(type.returnType), ...type.parameters.map((e) => getId(e.type))]); + } else if (type is DynamicType) { + ret = DynamicTypeData().id; + } else if (type is VoidType) { + ret = VoidTypeData().id; + } else if (type is NeverType) { + ret = NeverTypeData().id; + } else if (type is TypeParameterType) { + ret = getId(type.bound); + } else { + throw CommandsError('Unhandled type $type'); + } + + processing.removeLast(); + + if (type.nullabilitySuffix == NullabilitySuffix.question) { + // null can be assigned to dynamic, void or Never by default + if (![DynamicTypeData().id, VoidTypeData().id, NeverTypeData().id].contains(ret)) { + ret++; + } + } + + logger.finest('ID for type $type: $ret'); + + return ret; +} + +// We keep track of the IDs of special types + +/// The ID of the [Name] class type. +int get nameId => getId(nameClassElement!.thisType); +ClassElement? nameClassElement; + +/// The ID of the [Description] class type. +int get descriptionId => getId(descriptionClassElement!.thisType); +ClassElement? descriptionClassElement; + +/// The ID of the [Choices] class type. +int get choicesId => getId(choicesClassElement!.thisType); +ClassElement? choicesClassElement; + +/// The ID of the [UseConverter] class type. +int get useConverterId => getId(useConverterClassElement!.thisType); +ClassElement? useConverterClassElement; + +/// The ID of the [Autocomplete] class type. +int get autocompleteId => getId(autocompleteClassElement!.thisType); +ClassElement? autocompleteClassElement; + +/// The ID of the [Object] class type. +int get objectId => getId(objectClassElement!.thisType); +ClassElement? objectClassElement; + +/// The ID of the [Function] class type, +int get functionId => getId(functionClassElement!.thisType); +ClassElement? functionClassElement; + +Map, void Function(ClassElement)> _specialInterfaceTypeSetters = { + ['package:nyxx_commands/src/util/util.dart', 'Description']: (element) => + descriptionClassElement = element, + ['package:nyxx_commands/src/util/util.dart', 'Name']: (element) => nameClassElement = element, + ['package:nyxx_commands/src/util/util.dart', 'Choices']: (element) => + choicesClassElement = element, + ['package:nyxx_commands/src/util/util.dart', 'UseConverter']: (element) => + useConverterClassElement = element, + ['package:nyxx_commands/src/util/util.dart', 'Autocomplete']: (element) => + autocompleteClassElement = element, + ['dart:core/object.dart', 'Object']: (element) => objectClassElement = element, + ['dart:core/function.dart', 'Function']: (element) => functionClassElement = element, +}; + +/// Update the special types if needed. +void checkSpecialType(DartType type) { + if (type is InterfaceType) { + for (final key in _specialInterfaceTypeSetters.keys) { + if ((type.element.location?.components.contains(key[0]) ?? false) && + type.getDisplayString(withNullability: true) == key[1]) { + logger.finer('Found special type $key: ${type.element}'); + + _specialInterfaceTypeSetters[key]!(type.element); + } + } + } +} + +/// Takes a list of [types] and generates type data from them. +/// +/// The return value is a mapping of type IDs to their type data. +Map buildTree(List types) { + final Map result = {}; + + List processing = []; + + // Type parameters can have a different ID to their actual reflected type, so we merge them at + // the end. + Map toMerge = {}; + + void handle(DartType type) { + int id = getId(type); + + logger.finer('Handling type $type (ID $id)'); + + if (type is TypeParameterType) { + handle(type.bound); + + if (result.containsKey(id) || processing.contains(id)) { + return; + } + + int boundId = getId(type.bound); + + if (processing.contains(boundId)) { + toMerge[boundId] = id; + } else { + result[id] = result[boundId]!; + } + + return; + } + + if (result.containsKey(id) || processing.contains(id)) { + return; + } + + checkSpecialType(type); + + processing.add(id); + + if (type is InterfaceType) { + handle(type.element.thisType); + + for (final superType in type.allSupertypes) { + handle(superType); + } + + for (final typeArgument in type.typeArguments) { + handle(typeArgument); + } + + result[id] = InterfaceTypeData( + name: type.getDisplayString(withNullability: true), + source: type, + id: id, + strippedId: getId(type.element.thisType), + superClasses: type.allSupertypes.map(getId).toList(), + typeArguments: type.typeArguments.map(getId).toList(), + isNullable: type.nullabilitySuffix == NullabilitySuffix.question, + ); + } else if (type is FunctionType) { + handle(type.returnType); + + int requiredParameterCount = 0; + List positionalParameterTypes = []; + Map requiredNamedParameters = {}; + Map optionalNamedParameters = {}; + + for (final parameter in type.parameters) { + handle(parameter.type); + + if (parameter.isPositional) { + if (parameter.isRequiredPositional) { + requiredParameterCount++; + } + + positionalParameterTypes.add(getId(parameter.type)); + } else if (parameter.isRequiredNamed) { + requiredNamedParameters[parameter.name] = getId(parameter.type); + } else if (parameter.isOptionalNamed) { + optionalNamedParameters[parameter.name] = getId(parameter.type); + } else { + throw CommandsError('Unknown parameter type $parameter'); + } + } + + result[id] = FunctionTypeData( + name: type.getDisplayString(withNullability: true), + source: type, + id: id, + returnType: getId(type.returnType), + positionalParameterTypes: positionalParameterTypes, + requiredPositionalParametersCount: requiredParameterCount, + requiredNamedParametersType: requiredNamedParameters, + optionalNamedParametersType: optionalNamedParameters, + isNullable: type.nullabilitySuffix == NullabilitySuffix.question, + ); + } else if (type is DynamicType) { + result[id] = DynamicTypeData(); + } else if (type is VoidType) { + result[id] = VoidTypeData(); + } else if (type is NeverType) { + result[id] = NeverTypeData(); + } else { + throw CommandsError('Couldn\'t generate type data for type "${type.runtimeType}"'); + } + + processing.removeLast(); + + for (final key in toMerge.keys.toList()) { + if (!processing.contains(key)) { + result[toMerge.remove(key)!] = result[key]!; + } + } + } + + for (final type in types) { + handle(type); + } + + return result; +} diff --git a/bin/compile/type_tree/type_builder_visitor.dart b/bin/compile/type_tree/type_builder_visitor.dart new file mode 100644 index 0000000..e71e873 --- /dev/null +++ b/bin/compile/type_tree/type_builder_visitor.dart @@ -0,0 +1,76 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/analysis/analysis_context.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import '../element_tree_visitor.dart'; +import '../generator.dart'; + +/// An AST visitor that collects all the types referenced in an entire program. +class TypeBuilderVisitor extends EntireAstVisitor { + final List types = []; + + TypeBuilderVisitor(AnalysisContext context, bool slow) : super(context, slow); + + @override + void visitClassDeclaration(ClassDeclaration node) { + types.add(node.declaredElement!.thisType); + + logger.finest('Found class delcaration ${node.name}'); + + super.visitClassDeclaration(node); + } + + @override + void visitMixinDeclaration(MixinDeclaration node) { + types.add(node.declaredElement!.thisType); + + logger.finest('Found mixin declaration ${node.name}'); + + super.visitMixinDeclaration(node); + } + + @override + void visitFormalParameterList(FormalParameterList node) { + for (final parameter in node.parameterElements) { + types.add(parameter!.type); + + logger.finest('Found parameter type ${parameter.type}'); + } + + super.visitFormalParameterList(node); + } + + @override + void visitTypeLiteral(TypeLiteral node) { + types.add(node.type.type!); + + logger.finest('Found type literal $node'); + + super.visitTypeLiteral(node); + } + + @override + void visitTypeArgumentList(TypeArgumentList node) { + for (final argument in node.arguments) { + types.add(argument.type!); + + logger.finest('Found type argument ${argument.type}'); + } + + super.visitTypeArgumentList(node); + } +} diff --git a/bin/compile/type_tree/type_data.dart b/bin/compile/type_tree/type_data.dart new file mode 100644 index 0000000..2faad31 --- /dev/null +++ b/bin/compile/type_tree/type_data.dart @@ -0,0 +1,203 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/element/type.dart'; + +/// A representation of a type. +abstract class TypeData { + /// The ID of this type. + int get id; + + /// The [DartType] from which this type data was generated. + DartType get source; + + /// The name of this type. + String get name; +} + +/// A nullable type. +abstract class NullableTypeData { + /// Whether this type is nullable. + bool get isNullable; +} + +/// Type data for a class, enum or mixin ("interfaces"). +class InterfaceTypeData implements TypeData, NullableTypeData { + @override + int id; + + @override + final InterfaceType source; + + @override + String name; + + /// The "stripped id" of this type. + /// + /// This is the ID of this same type, but without any specific type arguments. + int strippedId; + + /// The super classes of this type. + List superClasses; + + /// The type arguments of this type. + List typeArguments; + + @override + bool isNullable; + + InterfaceTypeData({ + required this.name, + required this.source, + required this.id, + required this.strippedId, + required this.superClasses, + required this.typeArguments, + required this.isNullable, + }); + + @override + int get hashCode => id; + + @override + operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); + + @override + String toString() => 'InterfaceTypeData[id=$id, ' + 'source=$source, ' + 'name=$name, ' + 'strippedId=$strippedId, ' + 'superClasses=$superClasses, ' + 'typeArguments=$typeArguments, ' + 'isNullable=$isNullable]'; +} + +/// Type data for a function type. +class FunctionTypeData implements TypeData, NullableTypeData { + @override + int id; + + @override + final FunctionType source; + + @override + String name; + + /// The ID of the return type of this function. + int returnType; + + /// The types of the positional parameters of this function. + List positionalParameterTypes; + + /// The number of required parameters of this function. + int requiredPositionalParametersCount; + + /// The types of the optional named parameters of this function. + Map optionalNamedParametersType; + + /// The types of the required named parameters of this function. + Map requiredNamedParametersType; + + @override + bool isNullable; + + FunctionTypeData({ + required this.name, + required this.source, + required this.id, + required this.returnType, + required this.positionalParameterTypes, + required this.isNullable, + required this.requiredPositionalParametersCount, + required this.optionalNamedParametersType, + required this.requiredNamedParametersType, + }); + + @override + int get hashCode => id; + + @override + operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); + + @override + String toString() => 'FunctionTypeData[id=$id, ' + 'source=$source, ' + 'name=$name, ' + 'returnType=$returnType, ' + 'parameterTypes=$positionalParameterTypes, ' + 'isNullable=$isNullable]'; +} + +/// Type data for the `dynamic` type. +class DynamicTypeData implements TypeData { + @override + int id = 0; + + @override + DynamicType get source => throw UnsupportedError('Cannot get source for dynamic'); + + @override + String name = 'dynamic'; + + @override + int get hashCode => id; + + @override + operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); + + @override + String toString() => 'DynamicTypeData'; +} + +/// Type data for the `void` type. +class VoidTypeData implements TypeData { + @override + int id = 1; + + @override + VoidType get source => throw UnsupportedError('Cannot get source for void'); + + @override + String name = 'void'; + + @override + int get hashCode => id; + + @override + operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); + + @override + String toString() => 'VoidTypeData'; +} + +/// Type data for the "Never" type. +class NeverTypeData implements TypeData { + @override + int id = 2; + + @override + NeverType get source => throw UnsupportedError('Cannot get source for Never'); + + @override + String name = 'Never'; + + @override + int get hashCode => id; + + @override + operator ==(Object other) => identical(this, other) || (other is TypeData && other.id == id); + + @override + String toString() => 'NeverTypeData'; +} diff --git a/example/example.dart b/example/example.dart index 5c806ce..3ebf5e1 100644 --- a/example/example.dart +++ b/example/example.dart @@ -122,6 +122,10 @@ void main() { // The third parameter is the function that will be executed when the command is ran. // + // It is wrapped in a special function, `id`, that allows nyxx_commands to be compiled and ran + // as an executable. If you just want to run nyxx_commands with `dart run`, this is optional and + // you can just pass a normal function to the constructor. + // // The first parameter to this function must be a `IChatContext`. A `IChatContext` allows you to access // various information about how the command was run: the user that executed it, the guild it // was ran in and a few other useful pieces of information. @@ -129,12 +133,12 @@ void main() { // // Since a ping command doesn't have any other arguments, we don't add any other parameters to // the function. - (IChatContext context) { + id('ping', (IChatContext context) { // For a ping command, all we need to do is respond with `pong`. // To do that, we can use the `IChatContext`'s `respond` method which responds to the command with // a message. context.respond(MessageBuilder.content('pong!')); - }, + }), ); // Once we've created our command, we need to add it to our bot: @@ -183,12 +187,12 @@ void main() { ChatCommand( 'coin', 'Throw a coin', - (IChatContext context) { + id('throw-coin', (IChatContext context) { bool heads = Random().nextBool(); context.respond( MessageBuilder.content('The coin landed on its ${heads ? 'head' : 'tail'}!')); - }, + }), ), ], ); @@ -198,11 +202,11 @@ void main() { throwGroup.addCommand(ChatCommand( 'die', 'Throw a die', - (IChatContext context) { + id('throw-die', (IChatContext context) { int number = Random().nextInt(6) + 1; context.respond(MessageBuilder.content('The die landed on the $number!')); - }, + }), )); // Finally, just like the `ping` command, we need to add our command group to the bot: @@ -236,9 +240,9 @@ void main() { // As mentioned earlier, all we need to do to add an argument to our command is add it as a // parameter to our execute function. In this case, we take an argument called `message` and of // type `String`. - (IChatContext context, String message) { + id('say', (IChatContext context, String message) { context.respond(MessageBuilder.content(message)); - }, + }), ); // As usual, we need to register the command to our bot. @@ -294,7 +298,7 @@ void main() { "Change a user's nickname", // Setting the type of the `target` parameter to `IMember` will make nyxx_commands convert user // input to instances of `IMember`. - (IChatContext context, IMember target, String newNick) async { + id('nick', (IChatContext context, IMember target, String newNick) async { try { await target.edit(builder: MemberBuilder()..nick = newNick); } on IHttpResponseError { @@ -303,7 +307,7 @@ void main() { } context.respond(MessageBuilder.content('Successfully changed nickname!')); - }, + }), ); commands.addCommand(nick); @@ -436,7 +440,7 @@ void main() { ChatCommand favouriteShape = ChatCommand( 'favourite-shape', 'Outputs your favourite shape', - (IChatContext context, Shape shape, Dimension dimension) { + id('favourite-shape', (IChatContext context, Shape shape, Dimension dimension) { String favourite; switch (shape) { @@ -463,7 +467,7 @@ void main() { } context.respond(MessageBuilder.content('Your favourite shape is $favourite!')); - }, + }), ); commands.addCommand(favouriteShape); @@ -505,9 +509,9 @@ void main() { ChatCommand favouriteFruit = ChatCommand( 'favourite-fruit', 'Outputs your favourite fruit', - (IChatContext context, [String favourite = 'apple']) { + id('favourite-fruit', (IChatContext context, [String favourite = 'apple']) { context.respond(MessageBuilder.content('Your favourite fruit is $favourite!')); - }, + }), ); commands.addCommand(favouriteFruit); @@ -536,9 +540,9 @@ void main() { ChatCommand alphabet = ChatCommand( 'alphabet', 'Outputs the alphabet', - (IChatContext context) { + id('alphabet', (IChatContext context) { context.respond(MessageBuilder.content('ABCDEFGHIJKLMNOPQRSTUVWXYZ')); - }, + }), // Since this command is spammy, we can use a cooldown to restrict its usage: checks: [ CooldownCheck( @@ -578,12 +582,12 @@ void main() { ChatCommand betterSay = ChatCommand( 'better-say', 'A better version of the say command', - ( + id('better-say', ( IChatContext context, @UseConverter(nonEmptyStringConverter) String input, ) { context.respond(MessageBuilder.content(input)); - }, + }), ); commands.addCommand(betterSay); diff --git a/example/example_clean.dart b/example/example_clean.dart index c7b573c..cd8438e 100644 --- a/example/example_clean.dart +++ b/example/example_clean.dart @@ -38,9 +38,9 @@ void main() { ChatCommand ping = ChatCommand( 'ping', 'Checks if the bot is online', - (IChatContext context) { + id('ping', (IChatContext context) { context.respond(MessageBuilder.content('pong!')); - }, + }), ); commands.addCommand(ping); @@ -52,12 +52,12 @@ void main() { ChatCommand( 'coin', 'Throw a coin', - (IChatContext context) { + id('throw-coin', (IChatContext context) { bool heads = Random().nextBool(); context.respond( MessageBuilder.content('The coin landed on its ${heads ? 'head' : 'tail'}!')); - }, + }), ), ], ); @@ -65,11 +65,11 @@ void main() { throwGroup.addCommand(ChatCommand( 'die', 'Throw a die', - (IChatContext context) { + id('throw-die', (IChatContext context) { int number = Random().nextInt(6) + 1; context.respond(MessageBuilder.content('The die landed on the $number!')); - }, + }), )); commands.addCommand(throwGroup); @@ -77,9 +77,9 @@ void main() { ChatCommand say = ChatCommand( 'say', 'Make the bot say something', - (IChatContext context, String message) { + id('say', (IChatContext context, String message) { context.respond(MessageBuilder.content(message)); - }, + }), ); commands.addCommand(say); @@ -87,7 +87,7 @@ void main() { ChatCommand nick = ChatCommand( 'nick', "Change a user's nickname", - (IChatContext context, IMember target, String newNick) async { + id('nick', (IChatContext context, IMember target, String newNick) async { try { await target.edit(builder: MemberBuilder()..nick = newNick); } on IHttpResponseError { @@ -96,7 +96,7 @@ void main() { } context.respond(MessageBuilder.content('Successfully changed nickname!')); - }, + }), ); commands.addCommand(nick); @@ -142,7 +142,7 @@ void main() { ChatCommand favouriteShape = ChatCommand( 'favourite-shape', 'Outputs your favourite shape', - (IChatContext context, Shape shape, Dimension dimension) { + id('favourite-shape', (IChatContext context, Shape shape, Dimension dimension) { String favourite; switch (shape) { @@ -169,7 +169,7 @@ void main() { } context.respond(MessageBuilder.content('Your favourite shape is $favourite!')); - }, + }), ); commands.addCommand(favouriteShape); @@ -177,9 +177,9 @@ void main() { ChatCommand favouriteFruit = ChatCommand( 'favourite-fruit', 'Outputs your favourite fruit', - (IChatContext context, [String favourite = 'apple']) { + id('favourite-fruit', (IChatContext context, [String favourite = 'apple']) { context.respond(MessageBuilder.content('Your favourite fruit is $favourite!')); - }, + }), ); commands.addCommand(favouriteFruit); @@ -187,9 +187,9 @@ void main() { ChatCommand alphabet = ChatCommand( 'alphabet', 'Outputs the alphabet', - (IChatContext context) { + id('alphabet', (IChatContext context) { context.respond(MessageBuilder.content('ABCDEFGHIJKLMNOPQRSTUVWXYZ')); - }, + }), checks: [ CooldownCheck( CooldownType.user | CooldownType.guild, @@ -205,12 +205,12 @@ void main() { ChatCommand betterSay = ChatCommand( 'better-say', 'A better version of the say command', - ( + id('better-say', ( IChatContext context, @UseConverter(nonEmptyStringConverter) String input, ) { context.respond(MessageBuilder.content(input)); - }, + }), ); commands.addCommand(betterSay); diff --git a/lib/nyxx_commands.dart b/lib/nyxx_commands.dart index 15ad134..25752f6 100644 --- a/lib/nyxx_commands.dart +++ b/lib/nyxx_commands.dart @@ -1,7 +1,21 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + /// A framework for easily creating slash commands and text commands for Discord using the nyxx library. library nyxx_commands; -export 'src/checks/checks.dart' show AbstractCheck, Check, GuildCheck, RoleCheck, UserCheck; +export 'src/checks/checks.dart' show AbstractCheck, Check; export 'src/checks/context_type.dart' show ChatCommandCheck, @@ -11,6 +25,9 @@ export 'src/checks/context_type.dart' MessageCommandCheck, UserCommandCheck; export 'src/checks/cooldown.dart' show CooldownCheck, CooldownType; +export 'src/checks/guild.dart' show GuildCheck; +export 'src/checks/permissions.dart' show PermissionsCheck; +export 'src/checks/user.dart' show RoleCheck, UserCheck; export 'src/commands.dart' show CommandsPlugin; export 'src/commands/chat_command.dart' show ChatCommand, ChatGroup, CommandType; export 'src/commands/interfaces.dart' @@ -83,5 +100,6 @@ export 'src/util/util.dart' commandNameRegexp, convertToKebabCase, dmOr, + id, mentionOr; export 'src/util/view.dart' show StringView; diff --git a/lib/src/checks/checks.dart b/lib/src/checks/checks.dart index 76c809d..f490154 100644 --- a/lib/src/checks/checks.dart +++ b/lib/src/checks/checks.dart @@ -70,7 +70,31 @@ abstract class AbstractCheck { /// to a given role; /// - [CommandPermissionBuilderAbstract.user], for creating slash command permissions that apply /// to a given user. - Future> get permissions; + @Deprecated('Use allowsDm and requiredPermissions instead') + final Future> permissions = Future.value([]); + + /// Whether this check will allow commands to be executed in DM channels. + /// + /// If this is `false`, users will be unable to execute slash commands in DM channels with the + /// bot. However, users might still execute a text [ChatCommand] in DMs, so further validation in + /// the check itself is required. + /// + /// You might also be interested in: + /// - [requiredPermissions], for fine-tuning how commands can be executed in a guild. + FutureOr get allowsDm; + + /// The permissions required from members to pass this check. + /// + /// If this is `null` (or `Future`), all members will be allowed to execute the command. + /// + /// Members that do not have at least one of these permissions will see the command as unavailable + /// in their Discord client. However, users might still execute a text [ChatCommand], so further + /// validation in the check itself is required. + /// + /// You might also be interested in: + /// - [allowsDm], for controlling whether a command can be executed in a DM; + /// - [PermissionsConstants], for finding the integer that represents a certain permission. + FutureOr get requiredPermissions; /// An iterable of callbacks executed before a command is executed but after all the checks for /// that command have succeeded. @@ -129,9 +153,7 @@ abstract class AbstractCheck { /// Since some checks are so common, nyxx_commands provides a set of in-built checks that also /// integrate with the [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) /// API: -/// - [GuildCheck], for checking if a command was invoked in a specific guild; -/// - [RoleCheck], for checking if a command was invoked by a member with a specific role; -/// - [UserCheck], for checking if a command was invoked by a specific user. +/// - [GuildCheck], for checking if a command was invoked in a specific guild. /// /// You might also be interested in: /// - [Check.any], [Check.deny] and [Check.all], for modifying the behaviour of checks; @@ -139,13 +161,25 @@ abstract class AbstractCheck { class Check extends AbstractCheck { final FutureOr Function(IContext) _check; + @override + final FutureOr allowsDm; + + @override + final FutureOr requiredPermissions; + /// Create a new [Check]. /// /// [_check] should be a callback that returns `true` or `false` to indicate check success or /// failure respectively. [_check] should not throw to indicate failure. /// /// [name] can optionally be provided and will be used in error messages to identify this check. - Check(this._check, [String name = 'Check']) : super(name); + // TODO: Use named parameters instead of positional parameters + Check( + this._check, [ + String name = 'Check', + this.allowsDm = true, + this.requiredPermissions, + ]) : super(name); /// Creates a check that succeeds if any of [checks] succeeds. /// @@ -206,9 +240,6 @@ class Check extends AbstractCheck { @override FutureOr check(IContext context) => _check(context); - @override - Future> get permissions => Future.value([]); - @override Iterable get postCallHooks => []; @@ -244,24 +275,6 @@ class _AnyCheck extends AbstractCheck { return false; } - @override - Future> get permissions async { - Iterable> permissions = - await Future.wait(checks.map((check) => check.permissions)); - - return permissions.first.where( - (permission) => - permission.hasPermission || - // If permission is not granted, we check that it is not allowed by any of the other - // checks. If every check denies the permission for this id, also deny the permission in - // the combined version. - permissions.every((element) => element.any( - // CommandPermissionBuilderAbstract does not override == so we manually check it - (p) => p.id == permission.id && !p.hasPermission, - )), - ); - } - @override Iterable get preCallHooks => [ (context) { @@ -293,31 +306,41 @@ class _AnyCheck extends AbstractCheck { } } ]; -} -class _DenyCheck extends Check { - final AbstractCheck source; + @override + Future get allowsDm async { + for (final check in checks) { + if (await check.allowsDm) { + return true; + } + } - _DenyCheck(this.source, [String? name]) - : super((context) async => !(await source.check(context)), name ?? 'Denied ${source.name}'); + return false; + } @override - Future> get permissions async { - Iterable permissions = await source.permissions; + Future get requiredPermissions async { + int result = 0; - Iterable rolePermissions = - permissions.whereType(); + for (final check in checks) { + int? permissions = await check.requiredPermissions; + + if (permissions == null) { + return null; + } - Iterable userPermissions = - permissions.whereType(); + result |= permissions; + } - return [ - ...rolePermissions.map((permission) => CommandPermissionBuilderAbstract.role(permission.id, - hasPermission: !permission.hasPermission)), - ...userPermissions - .map((e) => CommandPermissionBuilderAbstract.user(e.id, hasPermission: !e.hasPermission)), - ]; + return result; } +} + +class _DenyCheck extends Check { + final AbstractCheck source; + + _DenyCheck(this.source, [String? name]) + : super((context) async => !(await source.check(context)), name ?? 'Denied ${source.name}'); // It may seem counterintuitive to call the success hooks if the source check failed, and this is // a situation where there is no proper solution. Here, we assume that the source check will @@ -327,6 +350,20 @@ class _DenyCheck extends Check { @override Iterable get postCallHooks => source.postCallHooks; + + @override + FutureOr get allowsDm async => !await source.allowsDm; + + @override + FutureOr get requiredPermissions async { + int? permissions = await source.requiredPermissions; + + if (permissions == null) { + return null; + } + + return ~permissions & PermissionsConstants.allPermissions; + } } class _GroupCheck extends Check { @@ -342,14 +379,6 @@ class _GroupCheck extends Check { return !syncResults.contains(false) && !(await Future.wait(asyncResults)).contains(false); }, name ?? 'All of [${checks.map((e) => e.name).join(', ')}]'); - @override - Future> get permissions async => - (await Future.wait(checks.map( - (e) => e.permissions, - ))) - .fold([], - (acc, element) => (acc as List)..addAll(element)); - @override Iterable get preCallHooks => checks.map((e) => e.preCallHooks).expand((_) => _); @@ -357,164 +386,32 @@ class _GroupCheck extends Check { @override Iterable get postCallHooks => checks.map((e) => e.postCallHooks).expand((_) => _); -} - -/// A check that checks that the user that executes a command has a specific role. -/// -/// This check integrates with the [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) -/// API, so users that cannot use a command because of this check will have that command appear -/// grayed out in their Discord client. -class RoleCheck extends Check { - /// The IDs of the roles this check allows. - Iterable roleIds; - - /// Create a new [RoleCheck] that succeeds if the user that created the context has [role]. - /// - /// You might also be interested in: - /// - [RoleCheck.id], for creating this same check without an instance of [IRole]; - /// - [RoleCheck.any], for checking that the user that created a context has one of a set or - /// roles. - RoleCheck(IRole role, [String? name]) : this.id(role.id, name); - - /// Create a new [RoleCheck] that succeeds if the user that created the context has a role with - /// the id [id]. - RoleCheck.id(Snowflake id, [String? name]) - : roleIds = [id], - super( - (context) => context.member?.roles.any((role) => role.id == id) ?? false, - name ?? 'Role Check on $id', - ); - - /// Create a new [RoleCheck] that succeeds if the user that created the context has any of [roles]. - /// - /// You might also be interested in: - /// - [RoleCheck.anyId], for creating this same check without instances of [IRole]. - RoleCheck.any(Iterable roles, [String? name]) - : this.anyId(roles.map((role) => role.id), name); - - /// Create a new [RoleCheck] that succeeds if the user that created the context has any role for - /// which the role's id is in [roles]. - RoleCheck.anyId(Iterable roles, [String? name]) - : roleIds = roles, - super( - (context) => context.member?.roles.any((role) => roles.contains(role.id)) ?? false, - name ?? 'Role Check on any of [${roles.join(', ')}]', - ); @override - Future> get permissions => Future.value([ - CommandPermissionBuilderAbstract.role(Snowflake.zero(), hasPermission: false), - ...roleIds.map((e) => CommandPermissionBuilderAbstract.role(e, hasPermission: true)), - ]); -} - -/// A check that checks that a command was executed by a specific user. -/// -/// This check integrates with the [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) -/// API, so users that cannot use a command because of this check will have that command appear -/// grayed out in their Discord client. -class UserCheck extends Check { - /// The IDs of the users this check allows. - Iterable userIds; - - /// Create a new [UserCheck] that succeeds if the context was created by [user]. - /// - /// You might also be interested in: - /// - [UserCheck.id], for creating this same check without an instance of [IUser], - /// - [UserCheck.any], for checking that a context was created by a user in a set or users. - UserCheck(IUser user, [String? name]) : this.id(user.id, name); - - /// Create a new [UserCheck] that succeeds if the ID of the user that created the context is [id]. - UserCheck.id(Snowflake id, [String? name]) - : userIds = [id], - super((context) => context.user.id == id, name ?? 'User Check on $id'); + FutureOr get allowsDm async { + for (final check in checks) { + if (!await check.allowsDm) { + return false; + } + } - /// Create a new [UserCheck] that succeeds if the context was created by any one of [users]. - /// - /// You might also be interested in: - /// - [UserCheck.anyId], for creating this same check without instance of [IUser]. - UserCheck.any(Iterable users, [String? name]) - : this.anyId(users.map((user) => user.id), name); - - /// Create a new [UserCheck] that succeeds if the ID of the user that created the context is in - /// [ids]. - UserCheck.anyId(Iterable ids, [String? name]) - : userIds = ids, - super( - (context) => ids.contains(context.user.id), - name ?? 'User Check on any of [${ids.join(', ')}]', - ); + return true; + } @override - Future> get permissions => Future.value([ - CommandPermissionBuilderAbstract.user(Snowflake.zero(), hasPermission: false), - ...userIds.map((e) => CommandPermissionBuilderAbstract.user(e, hasPermission: true)), - ]); -} - -/// A check that checks that a command was executed in a particular guild, or in a channel that is -/// not in a guild. -/// -/// This check is special as commands with this check will only be registered as slash commands in -/// the guilds specified by this guild check. For this functionality to work, however, this check -/// must be a "top-level" check - that is, a check that is not nested within a modifier such as -/// [Check.any], [Check.deny] or [Check.all]. -/// -/// The value of this check overrides [CommandsPlugin.guild]. -/// -/// You might also be interested in: -/// - [CommandsPlugin.guild], for globally setting a guild to register slash commands to. -class GuildCheck extends Check { - /// The IDs of the guilds that this check allows. - /// - /// If [guildIds] is `[null]`, then any guild is allowed, but not channels outside of guilds/ - Iterable guildIds; + FutureOr get requiredPermissions async { + Iterable permissions = checks.whereType(); - /// Create a [GuildCheck] that succeeds if the context originated in [guild]. - /// - /// You might also be interested in: - /// - [GuildCheck.id], for creating this same check without an instance of [IGuild]; - /// - [GuildCheck.any], for checking if the context originated in any of a set of guilds. - GuildCheck(IGuild guild, [String? name]) : this.id(guild.id, name); + if (permissions.isEmpty) { + return null; + } - /// Create a [GuildCheck] that succeeds if the ID of the guild the context originated in is [id]. - GuildCheck.id(Snowflake id, [String? name]) - : guildIds = [id], - super((context) => context.guild?.id == id, name ?? 'Guild Check on $id'); + int result = PermissionsConstants.allPermissions; - /// Create a [GuildCheck] that succeeds if the context originated outside of a guild (generally, - /// in private messages). - /// - /// You might also be interested in: - /// - [GuildCheck.all], for checking that a context originated in a guild. - GuildCheck.none([String? name]) - : guildIds = [], - super((context) => context.guild == null, name ?? 'Guild Check on '); + for (final permission in permissions) { + result &= permission; + } - /// Create a [GuildCheck] that succeeds if the context originated in a guild. - /// - /// You might also be interested in: - /// - [GuildCheck.none], for checking that a context originated outside a guild. - GuildCheck.all([String? name]) - : guildIds = [null], - super( - (context) => context.guild != null, - name ?? 'Guild Check on ', - ); - - /// Create a [GuildCheck] that succeeds if the context originated in any of [guilds]. - /// - /// You might also be interested in: - /// - [GuildCheck.anyId], for creating the same check without instances of [IGuild]. - GuildCheck.any(Iterable guilds, [String? name]) - : this.anyId(guilds.map((guild) => guild.id), name); - - /// Create a [GuildCheck] that succeeds if the id of the guild the context originated in is in - /// [ids]. - GuildCheck.anyId(Iterable ids, [String? name]) - : guildIds = ids, - super( - (context) => ids.contains(context.guild?.id), - name ?? 'Guild Check on any of [${ids.join(', ')}]', - ); + return result; + } } diff --git a/lib/src/checks/context_type.dart b/lib/src/checks/context_type.dart index aa66a68..94dcd40 100644 --- a/lib/src/checks/context_type.dart +++ b/lib/src/checks/context_type.dart @@ -1,8 +1,3 @@ -import 'dart:async'; - -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; - import '../context/interaction_context.dart'; import '../context/chat_context.dart'; import '../context/message_context.dart'; @@ -23,7 +18,11 @@ import 'checks.dart'; /// - [ChatCommandCheck], for checking that the command being invoked is a [ChatCommand]. class InteractionCommandCheck extends Check { /// Create a new [InteractionChatCommandCheck]. - InteractionCommandCheck() : super((context) => context is IInteractionContext); + InteractionCommandCheck([String? name]) + : super( + (context) => context is IInteractionContext, + name ?? 'Interaction check', + ); } /// A check that succeeds if the command being invoked is a [MessageCommand]. @@ -35,7 +34,11 @@ class InteractionCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class MessageCommandCheck extends Check { /// Create a new [MessageCommandCheck]. - MessageCommandCheck() : super((context) => context is MessageContext); + MessageCommandCheck([String? name]) + : super( + (context) => context is MessageContext, + name ?? 'Message command check', + ); } /// A check that succeeds if the command being invoked is a [UserCommand]. @@ -47,7 +50,11 @@ class MessageCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class UserCommandCheck extends Check { /// Create a new [UserCommandCheck]. - UserCommandCheck() : super((context) => context is UserContext); + UserCommandCheck([String? name]) + : super( + (context) => context is UserContext, + name ?? 'User command check', + ); } /// A check that succeeds if the command being invoked is a [ChatCommand]. @@ -65,7 +72,11 @@ class UserCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class ChatCommandCheck extends Check { /// Create a new [ChatCommandCheck]. - ChatCommandCheck() : super((context) => context is IChatContext); + ChatCommandCheck([String? name]) + : super( + (context) => context is IChatContext, + name ?? 'Chat command check', + ); } /// A check that succeeds if the command being invoked is a [ChatCommand] and that the context was @@ -82,7 +93,11 @@ class ChatCommandCheck extends Check { /// - [InteractionCommandCheck], for checking that a command was invoked from an interaction. class InteractionChatCommandCheck extends Check { /// Create a new [InteractionChatCommandCheck]. - InteractionChatCommandCheck() : super((context) => context is InteractionChatContext); + InteractionChatCommandCheck([String? name]) + : super( + (context) => context is InteractionChatContext, + name ?? 'Interaction chat command check', + ); } /// A check that succeeds if the command being invoked is a [ChatCommand] and that the context was @@ -98,10 +113,12 @@ class InteractionChatCommandCheck extends Check { /// - [ChatCommandCheck], for checking that the command being exected is a [ChatCommand]. class MessageChatCommandCheck extends Check { /// Create a new [MessageChatCommandCheck]. - MessageChatCommandCheck() : super((context) => context is MessageChatContext); - - @override - Future> get permissions => Future.value([ - CommandPermissionBuilderAbstract.role(Snowflake.zero(), hasPermission: false), - ]); + MessageChatCommandCheck([String? name]) + : super( + (context) => context is MessageChatContext, + name ?? 'Message chat command check', + // Disallow command in both guilds and DMs (0 = disable for all members). + false, + 0, + ); } diff --git a/lib/src/checks/cooldown.dart b/lib/src/checks/cooldown.dart index 2789f45..63d4f42 100644 --- a/lib/src/checks/cooldown.dart +++ b/lib/src/checks/cooldown.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_interactions/nyxx_interactions.dart'; import '../context/context.dart'; import 'checks.dart'; @@ -307,8 +306,11 @@ class CooldownCheck extends AbstractCheck { ]; @override - Future> get permissions => Future.value([]); + Iterable get postCallHooks => []; @override - Iterable get postCallHooks => []; + bool get allowsDm => true; + + @override + int? get requiredPermissions => null; } diff --git a/lib/src/checks/guild.dart b/lib/src/checks/guild.dart new file mode 100644 index 0000000..f6ba7f5 --- /dev/null +++ b/lib/src/checks/guild.dart @@ -0,0 +1,90 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:nyxx/nyxx.dart'; + +import 'checks.dart'; + +/// A check that checks that a command was executed in a particular guild, or in a channel that is +/// not in a guild. +/// +/// This check is special as commands with this check will only be registered as slash commands in +/// the guilds specified by this guild check. For this functionality to work, however, this check +/// must be a "top-level" check - that is, a check that is not nested within a modifier such as +/// [Check.any], [Check.deny] or [Check.all]. +/// +/// The value of this check overrides [CommandsPlugin.guild]. +/// +/// This check integrates with the [Discord Slash Command Permissions](https://discord.com/developers/docs/interactions/application-commands#permissions) +/// API, so users that cannot use a command because of this check will have that command appear +/// unavailable out in their Discord client. +/// +/// You might also be interested in: +/// - [CommandsPlugin.guild], for globally setting a guild to register slash commands to. +class GuildCheck extends Check { + /// The IDs of the guilds that this check allows. + /// + /// If [guildIds] is `[null]`, then any guild is allowed, but not channels outside of guilds. + Iterable guildIds; + + /// Create a [GuildCheck] that succeeds if the context originated in [guild]. + /// + /// You might also be interested in: + /// - [GuildCheck.id], for creating this same check without an instance of [IGuild]; + /// - [GuildCheck.any], for checking if the context originated in any of a set of guilds. + GuildCheck(IGuild guild, [String? name]) : this.id(guild.id, name); + + /// Create a [GuildCheck] that succeeds if the ID of the guild the context originated in is [id]. + GuildCheck.id(Snowflake id, [String? name]) + : guildIds = [id], + super((context) => context.guild?.id == id, name ?? 'Guild Check on $id', false); + + /// Create a [GuildCheck] that succeeds if the context originated outside of a guild (generally, + /// in private messages). + /// + /// You might also be interested in: + /// - [GuildCheck.all], for checking that a context originated in a guild. + GuildCheck.none([String? name]) + : guildIds = [], + super((context) => context.guild == null, name ?? 'Guild Check on ', true, 0); + + /// Create a [GuildCheck] that succeeds if the context originated in a guild. + /// + /// You might also be interested in: + /// - [GuildCheck.none], for checking that a context originated outside a guild. + GuildCheck.all([String? name]) + : guildIds = [null], + super( + (context) => context.guild != null, + name ?? 'Guild Check on ', + false, + ); + + /// Create a [GuildCheck] that succeeds if the context originated in any of [guilds]. + /// + /// You might also be interested in: + /// - [GuildCheck.anyId], for creating the same check without instances of [IGuild]. + GuildCheck.any(Iterable guilds, [String? name]) + : this.anyId(guilds.map((guild) => guild.id), name); + + /// Create a [GuildCheck] that succeeds if the id of the guild the context originated in is in + /// [ids]. + GuildCheck.anyId(Iterable ids, [String? name]) + : guildIds = ids, + super( + (context) => ids.contains(context.guild?.id), + name ?? 'Guild Check on any of [${ids.join(', ')}]', + false, + ); +} diff --git a/lib/src/checks/permissions.dart b/lib/src/checks/permissions.dart new file mode 100644 index 0000000..ab9a6e8 --- /dev/null +++ b/lib/src/checks/permissions.dart @@ -0,0 +1,170 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_commands/src/commands/interfaces.dart'; +import 'package:nyxx_commands/src/context/interaction_context.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; + +import 'checks.dart'; + +/// A check that succeeds if the member invoking the command has a certain set of permissions. +/// +/// You might also be interested in: +/// - [UserCheck], for checking if a command was executed by a specific user; +/// - [RoleCheck], for checking if a command was executed by a user with a specific role. +class PermissionsCheck extends Check { + /// The bitfield representing the permissions required by this check. + /// + /// You might also be interested in: + /// - [PermissionsConstants], for computing the value for this field; + /// - [AbstractCheck.requiredPermissions], for setting permissions on any check. + // TODO: Rename to `permissions` once AbstractCheck.permissions is removed + final int permissionsValue; + + /// Whether this check should allow server administrators to configure overrides that allow + /// specific users or channels to execute this command regardless of permissions. + final bool allowsOverrides; + + /// Whether this check requires the user invoking the command to have all of the permissions in + /// [permissionsValue] or only a single permission from [permissionsValue]. + /// + /// If this is true, the member invoking the command must have all the permissions in + /// [permissionsValue] to execute the command. Otherwise, members need only have one of the + /// permissions in [permissionsValue] to execute the command. + final bool requiresAll; + + /// Create a new [PermissionsCheck]. + PermissionsCheck( + this.permissionsValue, { + this.allowsOverrides = true, + this.requiresAll = false, + String? name, + bool allowsDm = true, + }) : super( + (context) async { + IMember? member = context.member; + + if (member == null) { + return allowsDm; + } + + IPermissions effectivePermissions = + await (context.channel as IGuildChannel).effectivePermissions(member); + + if (allowsOverrides) { + ISlashCommand command; + + if (context is IInteractionContext) { + command = context.interactionEvent.interactions.commands + .firstWhere((command) => command.id == context.interaction.commandId); + } else { + // If the invocation was not from a slash command, try to find a matching slash + // command and use the overrides from that. + ICommandRegisterable root = context.command; + + while (root.parent is ICommandRegisterable) { + root = root.parent as ICommandRegisterable; + } + + Iterable matchingCommands = + context.commands.interactions.commands.where( + (command) => command.name == root.name && command.type == SlashCommandType.chat, + ); + + if (matchingCommands.isEmpty) { + return false; + } + + command = matchingCommands.first; + } + + ISlashCommandPermissionOverrides overrides = + await command.getPermissionOverridesInGuild(context.guild!.id).getOrDownload(); + + if (overrides.permissionOverrides.isEmpty) { + overrides = await context.commands.interactions + .getGlobalOverridesInGuild(context.guild!.id) + .getOrDownload(); + } + + bool? def; + bool? channelDef; + bool? role; + bool? channel; + bool? user; + + int highestRoleIndex = -1; + + for (final override in overrides.permissionOverrides) { + if (override.isEveryone) { + def = override.allowed; + } else if (override.isAllChannels) { + channelDef = override.allowed; + } else if (override.type == SlashCommandPermissionType.channel && + override.id == context.channel.id) { + channel = override.allowed; + } else if (override.type == SlashCommandPermissionType.role) { + int roleIndex = -1; + + int i = 0; + for (final role in member.roles) { + if (role.id == override.id) { + roleIndex = i; + break; + } + + i++; + } + + if (highestRoleIndex < roleIndex) { + role = override.allowed; + highestRoleIndex = roleIndex; + } + } else if (override.type == SlashCommandPermissionType.user && + override.id == context.user.id) { + user = override.allowed; + // No need to continue if we found an override for the specific user + break; + } + } + + Iterable prioritised = [def, channelDef, role, channel, user].whereType(); + + if (prioritised.isNotEmpty) { + return prioritised.last; + } + } + + int corresponding = effectivePermissions.raw & permissionsValue; + + if (requiresAll) { + return corresponding == permissionsValue; + } + + return corresponding != 0; + }, + name ?? 'Permissions check on $permissionsValue', + allowsDm, + permissionsValue, + ); + + /// Create a [PermissionsCheck] that allows nobody to execute a command, unless configured + /// otherwise by a permission override. + PermissionsCheck.nobody({ + bool allowsOverrides = true, + String? name, + bool allowsDm = true, + }) : this(0, allowsOverrides: allowsOverrides, allowsDm: allowsDm, name: name); +} diff --git a/lib/src/checks/user.dart b/lib/src/checks/user.dart new file mode 100644 index 0000000..9a7e3d4 --- /dev/null +++ b/lib/src/checks/user.dart @@ -0,0 +1,90 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:nyxx/nyxx.dart'; + +import 'checks.dart'; + +/// A check that checks that a command was executed by a specific user. +class UserCheck extends Check { + /// The IDs of the users this check allows. + Iterable userIds; + + /// Create a new [UserCheck] that succeeds if the context was created by [user]. + /// + /// You might also be interested in: + /// - [UserCheck.id], for creating this same check without an instance of [IUser], + /// - [UserCheck.any], for checking that a context was created by a user in a set or users. + UserCheck(IUser user, [String? name]) : this.id(user.id, name); + + /// Create a new [UserCheck] that succeeds if the ID of the user that created the context is [id]. + UserCheck.id(Snowflake id, [String? name]) + : userIds = [id], + super((context) => context.user.id == id, name ?? 'User Check on $id'); + + /// Create a new [UserCheck] that succeeds if the context was created by any one of [users]. + /// + /// You might also be interested in: + /// - [UserCheck.anyId], for creating this same check without instance of [IUser]. + UserCheck.any(Iterable users, [String? name]) + : this.anyId(users.map((user) => user.id), name); + + /// Create a new [UserCheck] that succeeds if the ID of the user that created the context is in + /// [ids]. + UserCheck.anyId(Iterable ids, [String? name]) + : userIds = ids, + super( + (context) => ids.contains(context.user.id), + name ?? 'User Check on any of [${ids.join(', ')}]', + ); +} + +/// A check that checks that the user that executes a command has a specific role. +class RoleCheck extends Check { + /// The IDs of the roles this check allows. + Iterable roleIds; + + /// Create a new [RoleCheck] that succeeds if the user that created the context has [role]. + /// + /// You might also be interested in: + /// - [RoleCheck.id], for creating this same check without an instance of [IRole]; + /// - [RoleCheck.any], for checking that the user that created a context has one of a set or + /// roles. + RoleCheck(IRole role, [String? name]) : this.id(role.id, name); + + /// Create a new [RoleCheck] that succeeds if the user that created the context has a role with + /// the id [id]. + RoleCheck.id(Snowflake id, [String? name]) + : roleIds = [id], + super( + (context) => context.member?.roles.any((role) => role.id == id) ?? false, + name ?? 'Role Check on $id', + ); + + /// Create a new [RoleCheck] that succeeds if the user that created the context has any of [roles]. + /// + /// You might also be interested in: + /// - [RoleCheck.anyId], for creating this same check without instances of [IRole]. + RoleCheck.any(Iterable roles, [String? name]) + : this.anyId(roles.map((role) => role.id), name); + + /// Create a new [RoleCheck] that succeeds if the user that created the context has any role for + /// which the role's id is in [roles]. + RoleCheck.anyId(Iterable roles, [String? name]) + : roleIds = roles, + super( + (context) => context.member?.roles.any((role) => roles.contains(role.id)) ?? false, + name ?? 'Role Check on any of [${roles.join(', ')}]', + ); +} diff --git a/lib/src/commands.dart b/lib/src/commands.dart index ec444b5..0f2daa7 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -13,13 +13,13 @@ // limitations under the License. import 'dart:async'; -import 'dart:mirrors'; import 'package:logging/logging.dart'; import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; import 'checks/checks.dart'; +import 'checks/guild.dart'; import 'commands/chat_command.dart'; import 'commands/interfaces.dart'; import 'commands/message_command.dart'; @@ -31,6 +31,7 @@ import 'context/message_context.dart'; import 'context/user_context.dart'; import 'converters/converter.dart'; import 'errors.dart'; +import 'mirror_utils/mirror_utils.dart'; import 'options.dart'; import 'util/util.dart'; import 'util/view.dart'; @@ -508,28 +509,12 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { Future> _getSlashBuilders() async { List builders = []; - const Snowflake zeroSnowflake = Snowflake.zero(); - for (final command in children) { - if (!_shouldGnerateBuildersFor(command)) { - continue; - } - - Iterable permissions = await _getPermissions(command); - - if (permissions.length == 1 && - permissions.first.id == zeroSnowflake && - !permissions.first.hasPermission) { + if (!_shouldGenerateBuildersFor(command)) { continue; } - bool defaultPermission = true; - for (final permission in permissions) { - if (permission.id == zeroSnowflake) { - defaultPermission = permission.hasPermission; - break; - } - } + AbstractCheck allChecks = Check.all(command.checks); Iterable guildChecks = command.checks.whereType(); @@ -547,10 +532,8 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { List.of( _processHandlerRegistration(command.getOptions(this), command), ), - defaultPermissions: defaultPermission, - permissions: List.of( - permissions.where((permission) => permission.id != zeroSnowflake), - ), + canBeUsedInDm: await allChecks.allowsDm, + requiredPermissions: await allChecks.requiredPermissions, guild: guildId ?? guild, type: SlashCommandType.chat, ); @@ -567,10 +550,8 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { command.name, null, [], - defaultPermissions: defaultPermission, - permissions: List.of( - permissions.where((permission) => permission.id != zeroSnowflake), - ), + canBeUsedInDm: await allChecks.allowsDm, + requiredPermissions: await allChecks.requiredPermissions, guild: guildId ?? guild, type: SlashCommandType.user, ); @@ -583,10 +564,8 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { command.name, null, [], - defaultPermissions: defaultPermission, - permissions: List.of( - permissions.where((permission) => permission.id != zeroSnowflake), - ), + canBeUsedInDm: await allChecks.allowsDm, + requiredPermissions: await allChecks.requiredPermissions, guild: guildId ?? guild, type: SlashCommandType.message, ); @@ -602,7 +581,7 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { return builders; } - bool _shouldGnerateBuildersFor(ICommandRegisterable child) { + bool _shouldGenerateBuildersFor(ICommandRegisterable child) { if (child is IChatCommandComponent) { if (child.hasSlashCommand) { return true; @@ -614,40 +593,6 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { return true; } - Future> _getPermissions(IChecked command) async { - Map uniquePermissions = {}; - - for (final check in command.checks) { - Iterable checkPermissions = await check.permissions; - - for (final permission in checkPermissions) { - if (uniquePermissions.containsKey(permission.id) && - uniquePermissions[permission.id]!.hasPermission != permission.hasPermission) { - logger.warning( - 'Check "${check.name}" is in conflict with a previous check on ' - 'permissions for ' - '${permission.id.id == 0 ? 'the default permission' : 'id ${permission.id}'}. ' - 'Permission has been set to false to prevent unintended usage.', - ); - - if (permission is RoleCommandPermissionBuilder) { - uniquePermissions[permission.id] = - CommandPermissionBuilderAbstract.role(permission.id, hasPermission: false); - } else { - uniquePermissions[permission.id] = - CommandPermissionBuilderAbstract.user(permission.id, hasPermission: false); - } - - continue; - } - - uniquePermissions[permission.id] = permission; - } - } - - return uniquePermissions.values; - } - Iterable _processHandlerRegistration( Iterable options, IChatCommandComponent current, @@ -676,37 +621,24 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { ChatCommand command, ) { Iterator builderIterator = options.iterator; - Iterator argumentTypeIterator = command.argumentTypes.iterator; - - MethodMirror mirror = (reflect(command.execute) as ClosureMirror).function; - // Skip context argument - Iterable autocompleters = mirror.parameters.skip(1).map((parameter) { - Iterable annotations = parameter.metadata - .where((metadataMirror) => metadataMirror.hasReflectee) - .map((metadataMirror) => metadataMirror.reflectee) - .whereType(); - - if (annotations.isNotEmpty) { - return annotations.first; - } + Iterable parameters = loadFunctionData(command.execute) + .parametersData + // Skip context parameter + .skip(1); - return null; - }); + Iterator parameterIterator = parameters.iterator; - Iterator autocompletersIterator = autocompleters.iterator; + while (builderIterator.moveNext() && parameterIterator.moveNext()) { + Converter? converter = parameterIterator.current.converterOverride ?? + getConverter(parameterIterator.current.type); - while (builderIterator.moveNext() && - argumentTypeIterator.moveNext() && - autocompletersIterator.moveNext()) { FutureOr?> Function(AutocompleteContext)? autocompleteCallback = - autocompletersIterator.current?.callback; - - autocompleteCallback ??= getConverter(argumentTypeIterator.current)?.autocompleteCallback; + parameterIterator.current.autocompleteOverride ?? converter?.autocompleteCallback; if (autocompleteCallback != null) { builderIterator.current.registerAutocompleteHandler( - (event) => _processAutocompleteInteraction(event, autocompleteCallback!, command)); + (event) => _processAutocompleteInteraction(event, autocompleteCallback, command)); } } } @@ -740,17 +672,13 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { return _converters[type]!; } - TypeMirror targetMirror = reflectType(type); - List> assignable = []; List> superClasses = []; for (final key in _converters.keys) { - TypeMirror keyMirror = reflectType(key); - - if (keyMirror.isSubtypeOf(targetMirror)) { + if (isAssignableTo(key, type)) { assignable.add(_converters[key]!); - } else if (targetMirror.isSubtypeOf(keyMirror)) { + } else if (isAssignableTo(type, key)) { superClasses.add(_converters[key]!); } } @@ -759,7 +687,7 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { // Converters for types that superclass the target type might return an instance of the // target type. assignable.add(CombineConverter(converter, (superInstance, context) { - if (reflect(superInstance).type.isSubtypeOf(targetMirror)) { + if (isAssignableTo(superInstance.runtimeType, type)) { return superInstance; } return null; diff --git a/lib/src/commands/chat_command.dart b/lib/src/commands/chat_command.dart index 8926314..b192cf1 100644 --- a/lib/src/commands/chat_command.dart +++ b/lib/src/commands/chat_command.dart @@ -13,7 +13,6 @@ // limitations under the License. import 'dart:async'; -import 'dart:mirrors'; import 'package:nyxx_interactions/nyxx_interactions.dart'; @@ -23,6 +22,7 @@ import '../context/chat_context.dart'; import '../context/context.dart'; import '../converters/converter.dart'; import '../errors.dart'; +import '../mirror_utils/mirror_utils.dart'; import '../util/mixins.dart'; import '../util/util.dart'; import '../util/view.dart'; @@ -345,15 +345,7 @@ class ChatCommand @override final CommandOptions options; - late final MethodMirror _mirror; - late final Iterable _arguments; - late final int _requiredArguments; - final List _orderedArgumentNames = []; - final Map _mappedArgumentTypes = {}; - final Map _mappedArgumentMirrors = {}; - final Map _mappedDescriptions = {}; - final Map _mappedChoices = {}; - final Map _mappedConverterOverrides = {}; + late final FunctionData _functionData; /// Create a new [ChatCommand]. /// @@ -461,132 +453,33 @@ class ChatCommand } void _loadArguments(Function fn, Type contextType) { - _mirror = (reflect(fn) as ClosureMirror).function; + _functionData = loadFunctionData(fn); - Iterable arguments = _mirror.parameters; - - if (arguments.isEmpty) { + if (_functionData.parametersData.isEmpty) { throw CommandRegistrationError('Command callback function must have a Context parameter'); } - if (!reflectType(contextType).isAssignableTo(arguments.first.type)) { + if (!isAssignableTo(contextType, _functionData.parametersData.first.type)) { throw CommandRegistrationError( 'The first parameter of a command callback must be of type $contextType'); } - // Skip context argument - _arguments = arguments.skip(1); - _requiredArguments = _arguments.fold(0, (i, e) { - if (e.isOptional) { - return i; - } - return i + 1; - }); - - for (final parametrer in _arguments) { - if (!parametrer.type.hasReflectedType) { - throw CommandRegistrationError('Command callback parameters must have reflected types'); - } - if (parametrer.type.reflectedType == dynamic) { - throw CommandRegistrationError('Command callback parameters must not be of type "dynamic"'); - } - if (parametrer.isNamed) { - throw CommandRegistrationError('Command callback parameters must not be named parameters'); - } - if (parametrer.metadata.where((element) => element.reflectee is Description).length > 1) { - throw CommandRegistrationError( - 'Command callback parameters must not have more than one Description annotation'); - } - if (parametrer.metadata.where((element) => element.reflectee is Choices).length > 1) { - throw CommandRegistrationError( - 'Command callback parameters must not have more than one Choices annotation'); - } - if (parametrer.metadata.where((element) => element.reflectee is Name).length > 1) { - throw CommandRegistrationError( - 'Command callback parameters must not have more than one Name annotation'); - } - if (parametrer.metadata.where((element) => element.reflectee is UseConverter).length > 1) { - throw CommandRegistrationError( - 'Command callback parameters must not have more than one UseConverter annotation'); - } - if (parametrer.metadata.where((element) => element.reflectee is Autocomplete).length > 1) { - throw CommandRegistrationError( - 'Command callback parameters must not have more than one UseConverter annotation'); - } - } - - for (final argument in _arguments) { - argumentTypes.add(argument.type.reflectedType); - - Iterable names = argument.metadata - .where((element) => element.reflectee is Name) - .map((nameMirror) => nameMirror.reflectee) - .cast(); - - String argumentName; - if (names.isNotEmpty) { - argumentName = names.first.name; - - if (!commandNameRegexp.hasMatch(argumentName) || name != name.toLowerCase()) { - throw CommandRegistrationError('Invalid argument name "$argumentName"'); - } - } else { - String rawArgumentName = MirrorSystem.getName(argument.simpleName); - - argumentName = convertToKebabCase(rawArgumentName); - - if (!commandNameRegexp.hasMatch(argumentName) || name != name.toLowerCase()) { + // Skip context parameter + for (final parameter in _functionData.parametersData.skip(1)) { + if (parameter.description != null) { + if (parameter.description!.isEmpty || parameter.description!.length > 100) { throw CommandRegistrationError( - 'Could not convert parameter "$rawArgumentName" to a valid Discord ' - 'Slash command argument name (got "$argumentName")'); + 'Descriptions must not be empty nor longer than 100 characters'); } } - Iterable descriptions = argument.metadata - .where((element) => element.reflectee is Description) - .map((descriptionMirror) => descriptionMirror.reflectee) - .cast(); - - Description description; - if (descriptions.isNotEmpty) { - description = descriptions.first; - } else { - description = const Description('No description provided'); - } - - if (description.value.isEmpty || description.value.length > 100) { - throw CommandRegistrationError( - 'Descriptions must not be empty nor longer than 100 characters'); - } - - Iterable choices = argument.metadata - .where((element) => element.reflectee is Choices) - .map((choicesMirror) => choicesMirror.reflectee) - .cast(); - - if (choices.isNotEmpty) { - _mappedChoices[argumentName] = choices.first; - } - - Iterable converterOverrides = argument.metadata - .where((element) => element.reflectee is UseConverter) - .map((useConverterMirror) => useConverterMirror.reflectee) - .cast(); - - if (converterOverrides.isNotEmpty) { - UseConverter converterOverride = converterOverrides.first; - - if (!reflectType(converterOverride.converter.output).isAssignableTo(argument.type)) { + if (parameter.converterOverride != null) { + if (!isAssignableTo(parameter.converterOverride!.output, parameter.type)) { throw CommandRegistrationError('Invalid converter override'); } - - _mappedConverterOverrides[argumentName] = converterOverride; } - _mappedDescriptions[argumentName] = description; - _mappedArgumentTypes[argumentName] = argument.type.reflectedType; - _mappedArgumentMirrors[argumentName] = argument; - _orderedArgumentNames.add(argumentName); + argumentTypes.add(parameter.type); } } @@ -601,36 +494,36 @@ class ChatCommand if (context is MessageChatContext) { StringView argumentsView = StringView(context.rawArguments); - for (final argumentName in _orderedArgumentNames) { + for (final parameter in _functionData.parametersData.skip(1)) { if (argumentsView.eof) { break; } - Type expectedType = _mappedArgumentTypes[argumentName]!; - arguments.add(await parse( context.commands, context, argumentsView, - expectedType, - converterOverride: _mappedConverterOverrides[argumentName]?.converter, + parameter.type, + converterOverride: parameter.converterOverride, )); } - if (arguments.length < _requiredArguments) { + // Context parameter will be added in first position later + if (arguments.length < _functionData.requiredParameters - 1) { throw NotEnoughArgumentsException(context); } } else if (context is InteractionChatContext) { - for (final argumentName in _orderedArgumentNames) { - if (!context.rawArguments.containsKey(argumentName)) { - arguments.add(_mappedArgumentMirrors[argumentName]!.defaultValue?.reflectee); + for (final parameter in _functionData.parametersData.skip(1)) { + String kebabCaseName = convertToKebabCase(parameter.name); + + if (!context.rawArguments.containsKey(kebabCaseName)) { + arguments.add(parameter.defaultValue); continue; } - dynamic rawArgument = context.rawArguments[argumentName]!; - Type expectedType = _mappedArgumentTypes[argumentName]!; + dynamic rawArgument = context.rawArguments[kebabCaseName]!; - if (reflect(rawArgument).type.isAssignableTo(reflectType(expectedType))) { + if (isAssignableTo(rawArgument.runtimeType, parameter.type)) { arguments.add(rawArgument); continue; } @@ -638,9 +531,9 @@ class ChatCommand arguments.add(await parse( context.commands, context, - StringView(rawArgument.toString()), - expectedType, - converterOverride: _mappedConverterOverrides[argumentName]?.converter, + StringView(rawArgument.toString(), isRestBlock: true), + parameter.type, + converterOverride: parameter.converterOverride, )); } } @@ -669,33 +562,20 @@ class ChatCommand if (resolvedType != CommandType.textOnly) { List options = []; - for (final mirror in _arguments) { - Iterable names = mirror.metadata - .where((element) => element.reflectee is Name) - .map((nameMirror) => nameMirror.reflectee) - .cast(); + for (final parameter in _functionData.parametersData.skip(1)) { + Converter? argumentConverter = + parameter.converterOverride ?? commands.getConverter(parameter.type); - String name; - if (names.isNotEmpty) { - name = names.first.name; - } else { - String rawArgumentName = MirrorSystem.getName(mirror.simpleName); - - name = convertToKebabCase(rawArgumentName); - } - - Converter? argumentConverter = _mappedConverterOverrides[name]?.converter ?? - commands.getConverter(mirror.type.reflectedType); - - Iterable? choices = _mappedChoices[name]?.builders; + Iterable? choices = + parameter.choices?.entries.map((entry) => ArgChoiceBuilder(entry.key, entry.value)); choices ??= argumentConverter?.choices; CommandOptionBuilder builder = CommandOptionBuilder( argumentConverter?.type ?? CommandOptionType.string, - name, - _mappedDescriptions[name]!.value, - required: !mirror.isOptional, + convertToKebabCase(parameter.name), + parameter.description ?? 'No description provided', + required: !parameter.isOptional, choices: choices?.toList(), ); diff --git a/lib/src/context/autocomplete_context.dart b/lib/src/context/autocomplete_context.dart index 3e96007..6cf9a08 100644 --- a/lib/src/context/autocomplete_context.dart +++ b/lib/src/context/autocomplete_context.dart @@ -61,6 +61,24 @@ class AutocompleteContext implements IContextBase, IInteractionContextBase { /// for more. final String currentValue; + /// A map containing the arguments and the values that the user has inputted so far. + /// + /// The keys of this map depend on the names of the arguments set in [command]. If a user has not + /// yet filled in an argument, it will not be present in this map. + /// + /// The values might contain partial data. + late final Map existingArguments = Map.fromEntries( + interactionEvent.options.map((option) => MapEntry(option.name, option.value.toString())), + ); + + /// A map containing the arguments of [command] and their value, if the user has inputted a value + /// for them. + /// + /// The keys of this map depend on the names of the arguments set in [command]. + /// + /// The values might contain partial data. + late final Map arguments; + /// Create a new [AutocompleteContext]. AutocompleteContext({ required this.commands, @@ -74,5 +92,17 @@ class AutocompleteContext implements IContextBase, IInteractionContextBase { required this.interactionEvent, required this.option, required this.currentValue, - }); + }) { + ISlashCommand command = commands.interactions.commands.singleWhere( + (command) => command.id == interaction.commandId, + ); + + arguments = Map.fromIterable( + command.options.map((option) => option.name), + value: (option) => existingArguments[option], + ); + } + + /// Whether the user has inputted a value for an argument with the name [name]. + bool hasArgument(String name) => existingArguments.containsKey(name); } diff --git a/lib/src/converters/converter.dart b/lib/src/converters/converter.dart index e9db498..7eaaff2 100644 --- a/lib/src/converters/converter.dart +++ b/lib/src/converters/converter.dart @@ -392,7 +392,7 @@ class DoubleConverter extends NumConverter { double? max, }) : super( convertDouble, - type: CommandOptionType.integer, + type: CommandOptionType.number, min: min, max: max, ); diff --git a/lib/src/mirror_utils/compiled.dart b/lib/src/mirror_utils/compiled.dart new file mode 100644 index 0000000..ce91061 --- /dev/null +++ b/lib/src/mirror_utils/compiled.dart @@ -0,0 +1,199 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:nyxx_commands/nyxx_commands.dart'; +import 'package:nyxx_commands/src/mirror_utils/mirror_utils.dart'; +import 'package:nyxx_commands/src/util/util.dart'; + +Map? _typeTree; +Map? _typeMappings; +Map? _functionData; + +bool isAssignableTo(Type instance, Type target) { + if (_typeTree == null || _typeMappings == null) { + throw CommandsError('Type data was not correctly loaded. Did you compile the wrong file?'); + } + + int? instanceId = _typeMappings?[instance]; + int? targetId = _typeMappings?[target]; + + if (instanceId == null) { + throw CommandsException('Couldnt find type data for type $instance'); + } else if (targetId == null) { + throw CommandsException('Couldnt find type data for type $target'); + } + + return _isAAssignableToB(instanceId, targetId, _typeTree!); +} + +bool _isAAssignableToB(int aId, int bId, Map typeTree) { + TypeData a = typeTree[aId]!; + TypeData b = typeTree[bId]!; + + // Identical + if (a.id == b.id) { + // x => x + return true; + } + + // Never + if (b is NeverTypeData || a is NeverTypeData) { + // * => Never || Never => * + return false; + } + + // Dynamic and void + if (b is VoidTypeData) { + // * - {Never} => void + return true; + } + + if (a is VoidTypeData) { + // void => * - {void, Never} + return false; + } + + if (b is DynamicTypeData) { + // * - {void, Never} => dynamic + return true; + } + + if (a is DynamicTypeData) { + // dynamic => * - {void, Never, dynamic} + return false; + } + + // Object to function + if (a is! FunctionTypeData && b is FunctionTypeData) { + // * - {Function} => Function + return false; + } + + // Object to object + if (a is InterfaceTypeData && b is InterfaceTypeData) { + if (a.strippedId == b.strippedId) { + // A and B are the same class with different type arguments. Check if the type arguments + // are subtypes. + for (int i = 0; i < a.typeArguments.length; i++) { + if (!_isAAssignableToB(a.typeArguments[i], b.typeArguments[i], typeTree)) { + return false; + } + } + + return b.isNullable || !a.isNullable; + } else { + // A and B are different classes. Check if one of A's supertypes is assignable to B + for (final superId in a.superClasses) { + if (_isAAssignableToB(superId, bId, typeTree)) { + return true; + } + } + + return false; + } + } else if (a is FunctionTypeData && b is InterfaceTypeData) { + // Functions can only be assigned to [Object] and [Function] interface types + return (b.id == _typeMappings![Object]! || b.id == _typeMappings![Function]!) && + (b.isNullable || !a.isNullable); + } else if (a is InterfaceTypeData && b is FunctionTypeData) { + // Objects cannot be assigned to functions + return false; + } else if (a is FunctionTypeData && b is FunctionTypeData) { + if (a.positionalParameterTypes.length > b.positionalParameterTypes.length) { + return false; + } + + if (b.requiredPositionalParametersCount > a.requiredPositionalParametersCount) { + return false; + } + + // Parameter types can be widened but not narrowed + for (int i = 0; i < a.positionalParameterTypes.length; i++) { + if (!_isAAssignableToB( + a.positionalParameterTypes[i], b.positionalParameterTypes[i], typeTree)) { + return false; + } + } + + for (final entry in a.requiredNamedParametersType.entries) { + String name = entry.key; + int id = entry.value; + + // Required named parameters in a must be in b, but can be either required or optional + int? matching = b.requiredNamedParametersType[name] ?? b.optionalNamedParametersType[name]; + + if (matching == null) { + return false; + } + + if (!_isAAssignableToB(id, matching, typeTree)) { + return false; + } + } + + for (final entry in a.optionalNamedParametersType.entries) { + String name = entry.key; + int id = entry.value; + + // Optional named parameters in a must also be optional in b + int? matching = b.optionalNamedParametersType[name]; + + if (matching == null) { + return false; + } + + if (!_isAAssignableToB(id, matching, typeTree)) { + return false; + } + } + + // Return type can be narrowed but not widened + if (!_isAAssignableToB(b.returnType, a.returnType, typeTree)) { + return false; + } + + return b.isNullable || !a.isNullable; + } + + throw CommandsException( + 'Unhandled assignability check between types ' + '"${a.runtimeType}" and "${b.runtimeType}"', + ); +} + +FunctionData loadFunctionData(Function fn) { + if (_functionData == null) { + throw CommandsError('Function data was not correctly loaded. Did you compile the wrong file?'); + } + + dynamic id = idMap[fn]; + + FunctionData? result = _functionData![id]; + + if (result == null) { + throw CommandsException("Couldn't load function data for function $fn"); + } + + return result; +} + +void loadData( + Map typeTree, + Map typeMappings, + Map functionData, +) { + _typeTree = typeTree; + _typeMappings = typeMappings; + _functionData = functionData; +} diff --git a/lib/src/mirror_utils/function_data.dart b/lib/src/mirror_utils/function_data.dart new file mode 100644 index 0000000..dabe100 --- /dev/null +++ b/lib/src/mirror_utils/function_data.dart @@ -0,0 +1,56 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:nyxx_commands/src/context/autocomplete_context.dart'; +import 'package:nyxx_commands/src/converters/converter.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; + +class FunctionData { + final List parametersData; + + int get requiredParameters => parametersData.takeWhile((value) => !value.isOptional).length; + + const FunctionData(this.parametersData); +} + +class ParameterData { + final String name; + + final Type type; + + final bool isOptional; + + final String? description; + + final dynamic defaultValue; + + final Map? choices; + + final Converter? converterOverride; + + final FutureOr?> Function(AutocompleteContext)? autocompleteOverride; + + const ParameterData({ + required this.name, + required this.type, + required this.isOptional, + required this.description, + required this.defaultValue, + required this.choices, + required this.converterOverride, + required this.autocompleteOverride, + }); +} diff --git a/lib/src/mirror_utils/mirror_utils.dart b/lib/src/mirror_utils/mirror_utils.dart new file mode 100644 index 0000000..ea32a31 --- /dev/null +++ b/lib/src/mirror_utils/mirror_utils.dart @@ -0,0 +1,17 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export 'compiled.dart' if (dart.library.mirrors) 'with_mirrors.dart'; +export 'function_data.dart'; +export 'type_data.dart'; diff --git a/lib/src/mirror_utils/type_data.dart b/lib/src/mirror_utils/type_data.dart new file mode 100644 index 0000000..c84ed5e --- /dev/null +++ b/lib/src/mirror_utils/type_data.dart @@ -0,0 +1,109 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +abstract class TypeData { + int get id; + + String get name; +} + +abstract class NullableTypeData { + bool get isNullable; +} + +class InterfaceTypeData implements TypeData, NullableTypeData { + @override + final int id; + + @override + final String name; + + final int strippedId; + + final List superClasses; + + final List typeArguments; + + @override + final bool isNullable; + + const InterfaceTypeData({ + required this.name, + required this.id, + required this.strippedId, + required this.superClasses, + required this.typeArguments, + required this.isNullable, + }); +} + +class FunctionTypeData implements TypeData, NullableTypeData { + @override + final int id; + + @override + final String name; + + final int returnType; + + final List positionalParameterTypes; + final int requiredPositionalParametersCount; + + final Map requiredNamedParametersType; + final Map optionalNamedParametersType; + + @override + final bool isNullable; + + const FunctionTypeData({ + required this.name, + required this.id, + required this.returnType, + required this.positionalParameterTypes, + required this.requiredPositionalParametersCount, + required this.requiredNamedParametersType, + required this.optionalNamedParametersType, + required this.isNullable, + }); +} + +class DynamicTypeData implements TypeData { + @override + final int id = 0; + + @override + final String name = 'dynamic'; + + const DynamicTypeData(); +} + +class VoidTypeData implements TypeData { + @override + final int id = 1; + + @override + final String name = 'void'; + + const VoidTypeData(); +} + +class NeverTypeData implements TypeData { + @override + final int id = 2; + + @override + final String name = 'Never'; + + const NeverTypeData(); +} diff --git a/lib/src/mirror_utils/with_mirrors.dart b/lib/src/mirror_utils/with_mirrors.dart new file mode 100644 index 0000000..96db784 --- /dev/null +++ b/lib/src/mirror_utils/with_mirrors.dart @@ -0,0 +1,119 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:mirrors'; + +import 'package:nyxx_commands/src/commands.dart'; +import 'package:nyxx_commands/src/mirror_utils/mirror_utils.dart'; +import 'package:nyxx_commands/nyxx_commands.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; + +bool isAssignableTo(Type instance, Type target) => + instance == target || reflectType(instance).isSubtypeOf(reflectType(target)); + +FunctionData loadFunctionData(Function fn) { + List parametersData = []; + + MethodMirror fnMirror = (reflect(fn) as ClosureMirror).function; + + for (final parameterMirror in fnMirror.parameters) { + if (parameterMirror.isNamed) { + throw CommandRegistrationError( + 'Cannot load function data for functions with named parameters', + ); + } + + // Get parameter name + String name = MirrorSystem.getName(parameterMirror.simpleName); + + // Get parameter type + Type type = + parameterMirror.type.hasReflectedType ? parameterMirror.type.reflectedType : dynamic; + + Iterable getAnnotations() => + parameterMirror.metadata.map((e) => e.reflectee).whereType(); + + // Get parameter description (if any) + + Iterable descriptionAnnotations = getAnnotations(); + if (descriptionAnnotations.length > 1) { + throw CommandRegistrationError('parameters may have at most one Description annotation'); + } + + String? description; + if (descriptionAnnotations.isNotEmpty) { + description = descriptionAnnotations.first.value; + } + + // Get parameter choices + + Iterable choicesAnnotations = getAnnotations(); + if (choicesAnnotations.length > 1) { + throw CommandRegistrationError('parameters may have at most one Choices decorator'); + } + + Map? choices; + if (choicesAnnotations.isNotEmpty) { + choices = choicesAnnotations.first.choices; + } + + // Get parameter converter override + + Iterable useConverterAnnotations = getAnnotations(); + if (useConverterAnnotations.length > 1) { + throw CommandRegistrationError('parameters may have at most one UseConverter decorator'); + } + + Converter? converterOverride; + if (useConverterAnnotations.isNotEmpty) { + converterOverride = useConverterAnnotations.first.converter; + } + + // Get parameter autocomplete override + + Iterable autocompleteAnnotations = getAnnotations(); + if (autocompleteAnnotations.length > 1) { + throw CommandRegistrationError('parameters may have at most one Autocomplete decorator'); + } + + FutureOr?> Function(AutocompleteContext)? autocompleteOverride; + if (autocompleteAnnotations.isNotEmpty) { + autocompleteOverride = autocompleteAnnotations.first.callback; + } + + parametersData.add(ParameterData( + name: name, + type: type, + isOptional: parameterMirror.isOptional, + description: description, + defaultValue: parameterMirror.defaultValue?.reflectee, + choices: choices, + converterOverride: converterOverride, + autocompleteOverride: autocompleteOverride, + )); + } + + return FunctionData(parametersData); +} + +void loadData( + Map typeTree, + Map typeMappings, + Map functionData, +) { + if (const bool.fromEnvironment('dart.library.mirrors')) { + logger.info('Loading compiled function data when `dart:mirrors` is availible is unneeded'); + } +} diff --git a/lib/src/util/util.dart b/lib/src/util/util.dart index 48cd885..f6b7089 100644 --- a/lib/src/util/util.dart +++ b/lib/src/util/util.dart @@ -281,3 +281,20 @@ final RegExp commandNameRegexp = RegExp( r'^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$', unicode: true, ); + +final Map idMap = {}; + +/// A special function that can be wrapped around another function in order to tell nyxx_commands +/// how to identify the funcion at compile time. +/// +/// This function is used to identify a callback function so that compiled nyxx_commands can extract +/// the type & annotation data for that function. +/// +/// It is a compile-time error for two [id] invocations to share the same [id] parameter. +/// It is a runtime error in compiled nyxx_commands to create a [ChatCommand] with a non-wrapped +/// function. +T id(dynamic id, T fn) { + idMap[fn] = id; + + return fn; +} diff --git a/lib/src/util/view.dart b/lib/src/util/view.dart index 7dae050..6c6c8ea 100644 --- a/lib/src/util/view.dart +++ b/lib/src/util/view.dart @@ -52,10 +52,27 @@ class StringView { /// A record of all the previous indices the cursor was at preceding an operation. List history = []; + int? _restIsBlockFromIndex; + + /// Whether [remaining] should be considered to be one "block" of text, which [getQuotedWord] will + /// return all of. + /// + /// This will be reset to `false` whenever [index] changes. + bool get isRestBlock => index == _restIsBlockFromIndex; + set isRestBlock(bool value) { + if (value) { + _restIsBlockFromIndex = index; + } else { + _restIsBlockFromIndex = null; + } + } + /// Create a new [StringView] wrapping [buffer]. /// /// The cursor will initially be positioned at the start of [buffer]. - StringView(this.buffer); + StringView(this.buffer, {bool isRestBlock = false}) { + this.isRestBlock = isRestBlock; + } /// The largest possible index for the cursor. int get end => buffer.length; @@ -165,6 +182,8 @@ class StringView { /// between an opening quote and a corresponding, non-escaped closing quote if the next word /// begins with a quote. The quotes are consumed but not returned. /// + /// If [isRestBlock] is `true`, [remaining] is returned. + /// /// The word or quoted sequence is escaped before it is returned. /// /// You might also be interested in: @@ -173,7 +192,13 @@ class StringView { String getQuotedWord() { skipWhitespace(); - if (_quotes.containsKey(current)) { + if (isRestBlock) { + String content = remaining; + + index = end; + + return content; + } else if (_quotes.containsKey(current)) { String closingQuote = _quotes[current]!; index++; diff --git a/pubspec.yaml b/pubspec.yaml index cc640c2..96298fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_commands -version: 4.1.2 +version: 4.2.0 description: A framework for easily creating slash commands and text commands for Discord using the nyxx library. homepage: https://github.com/nyxx-discord/nyxx_commands/blob/main/README.md @@ -7,14 +7,18 @@ repository: https://github.com/nyxx-discord/nyxx_commands issue_tracker: https://github.com/nyxx-discord/nyxx_commands/issues environment: - sdk: '>=2.15.0 <3.0.0' + sdk: '>=2.17.0 <3.0.0' dependencies: + analyzer: ^3.4.1 + args: ^2.3.0 + dart_style: ^2.2.1 logging: ^1.0.2 meta: ^1.7.0 nyxx: ^3.0.0 - nyxx_interactions: ^4.1.0 + nyxx_interactions: ^4.2.1 random_string: ^2.3.1 + path: ^1.8.1 dev_dependencies: build_runner: ^2.1.4 @@ -22,3 +26,6 @@ dev_dependencies: lints: ^1.0.1 mockito: ^5.0.16 test: ^1.19.0 + +executables: + nyxx-compile: compile diff --git a/test/integration/compile_test.dart b/test/integration/compile_test.dart new file mode 100644 index 0000000..163a908 --- /dev/null +++ b/test/integration/compile_test.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../../bin/compile/generator.dart'; + +void main() { + test('Compilation script', () async { + Future generationFuture = generate('example/example.dart', 'out.g.dart', true, false); + + expect(generationFuture, completes); + + await generationFuture; + + expect(File('out.g.dart').exists(), completion(equals(true))); + + Future compilationFuture = Process.run( + Platform.executable, + ['compile', 'exe', 'out.g.dart'], + ); + + expect( + compilationFuture.then((value) => value.exitCode), + completion(equals(0)), // Expect compilation to succeed + ); + + await compilationFuture; + + expect(File('out.g.exe').exists(), completion(equals(true))); + }, timeout: Timeout(Duration(minutes: 10))); +}