From aa70e7bffe3df8bed2cf7d5b2f29b08098441f95 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Wed, 25 Mar 2026 19:35:23 +0100 Subject: [PATCH 1/6] create unsafe_imports lint rule --- packages/jaspr_lints/lib/main.dart | 5 + .../lib/src/rules/unsafe_imports_rule.dart | 231 ++++++++++ .../jaspr_lints/lib/src/utils/logging.dart | 33 ++ .../jaspr_lints/lib/src/utils/scope_tree.dart | 409 ++++++++++++++++++ 4 files changed, 678 insertions(+) create mode 100644 packages/jaspr_lints/lib/src/rules/unsafe_imports_rule.dart create mode 100644 packages/jaspr_lints/lib/src/utils/logging.dart create mode 100644 packages/jaspr_lints/lib/src/utils/scope_tree.dart diff --git a/packages/jaspr_lints/lib/main.dart b/packages/jaspr_lints/lib/main.dart index b6bd284ce..95c4a40e9 100644 --- a/packages/jaspr_lints/lib/main.dart +++ b/packages/jaspr_lints/lib/main.dart @@ -9,6 +9,8 @@ import 'src/rules/prefer_html_components_rule.dart'; import 'src/rules/prefer_styles_getter_rule.dart'; import 'src/rules/sort_children_last_rule.dart'; import 'src/rules/styles_ordering_rule.dart'; +import 'src/rules/unsafe_imports_rule.dart'; +import 'src/utils/logging.dart'; final plugin = JasprPlugin(); @@ -18,6 +20,9 @@ class JasprPlugin extends Plugin { @override void register(PluginRegistry registry) { + log('REGISTERING'); + registry.registerWarningRule(UnsafeImportsRule()); + registry.registerLintRule(SortChildrenLastRule()); registry.registerFixForRule(SortChildrenLastRule.code, SortChildrenLastFix.new); diff --git a/packages/jaspr_lints/lib/src/rules/unsafe_imports_rule.dart b/packages/jaspr_lints/lib/src/rules/unsafe_imports_rule.dart new file mode 100644 index 000000000..962d81b74 --- /dev/null +++ b/packages/jaspr_lints/lib/src/rules/unsafe_imports_rule.dart @@ -0,0 +1,231 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/diagnostic/diagnostic.dart'; +import 'package:yaml/yaml.dart'; + +import '../utils.dart'; +import '../utils/scope_tree.dart'; + +class UnsafeImportsRule extends AnalysisRule { + static const LintCode code = LintCode( + 'unsafe_imports', + 'Unsafe import: {0}', + severity: DiagnosticSeverity.ERROR, + ); + + UnsafeImportsRule() + : super( + name: 'unsafe_imports', + description: 'Detects unsafe platform imports.', + ); + + final ScopeTree scopeTree = ScopeTree(); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors(RuleVisitorRegistry registry, RuleContext context) { + final library = context.libraryElement!; + + final node = scopeTree.analyzeLibrary(library); + + if (node.serverScopeLocation != null) { + registry.addImportDirective(this, ServerScopeImportsVisitor(this, node)); + } + + if (node.clientScopeLocation != null) { + _checkPubspecConfig(context); + registry.addImportDirective(this, ClientScopeImportsVisitor(this, node, allowFlutterLibsInClient)); + } + + if (context.package?.root case final root?) { + scopeTree.writeScopes(root.path); + } + } + + int pubspecDigest = 0; + bool allowFlutterLibsInClient = false; + void _checkPubspecConfig(RuleContext context) { + final session = context.libraryElement?.session; + if (session == null) return; + final pubspecFile = session.analysisContext.contextRoot.root.getChildAssumingFile('pubspec.yaml'); + final digest = pubspecFile.lengthSync ^ pubspecFile.modificationStamp; + if (digest == pubspecDigest) return; + pubspecDigest = digest; + + final pubspec = loadYaml(pubspecFile.readAsStringSync()) as YamlMap; + if (pubspec case {'jaspr': {'flutter': 'embedded' || 'plugins'}}) { + allowFlutterLibsInClient = true; + } else { + allowFlutterLibsInClient = false; + } + } +} + +abstract class ScopeImportsVisitor extends SimpleAstVisitor { + ScopeImportsVisitor(this.rule, this.treeNode, {required this.edgeType}); + + final UnsafeImportsRule rule; + final ScopeTreeNode treeNode; + final EdgeType edgeType; + + @override + void visitImportDirective(ImportDirective node) { + for (final edge in treeNode.childEdges) { + if (allowsEdge(edge) && edge.directive.toSource() == node.toSource()) { + if (isUnsafeImport(edge.childNode.library)) { + reportUnsafeDirectImport(edge); + } else { + checkUnsafeTransitiveImports(edge); + } + } + } + } + + bool allowsEdge(ScopeTreeEdge edge) { + return edge.type == EdgeType.general || edge.type == edgeType; + } + + void reportUnsafeDirectImport(ScopeTreeEdge edge) { + rule.reportAtNode( + edge.directive, + arguments: [ + "'${edge.directive.uri.stringValue}' is not available on the ${edgeType.name}.\n${suggestionFor(edge.childNode.library)}", + ], + ); + } + + void checkUnsafeTransitiveImports(ScopeTreeEdge edge) { + final visited = {}; + final messages = []; + + late void Function(ScopeTreeEdge) recurseEdge; + + void recurseEdges(ScopeTreeNode node) { + for (final childEdge in node.childEdges) { + if (allowsEdge(childEdge)) { + recurseEdge(childEdge); + } + } + } + + recurseEdge = (ScopeTreeEdge childEdge) { + if (visited.contains(childEdge.directive.uri.stringValue)) return; + visited.add(childEdge.directive.uri.stringValue!); + + if (isUnsafeImport(childEdge.childNode.library)) { + final importUri = childEdge.configuration?.uri ?? childEdge.directive.uri; + messages.add( + SimpleDiagnosticMessage( + filePath: childEdge.parentNode.library.firstFragment.source.fullName, + offset: importUri.offset, + length: importUri.length, + message: "Unsafe import '${importUri.stringValue}'. ${suggestionFor(childEdge.childNode.library)}", + ), + ); + return; + } + + recurseEdges(childEdge.childNode); + }; + + recurseEdges(edge.childNode); + + if (messages.isNotEmpty) { + rule.reportAtNode( + edge.directive, + arguments: [ + "'${edge.directive.uri.stringValue}' imports other unsafe libraries which are not available on the ${edgeType.name}. See below for details.", + ], + contextMessages: messages, + ); + } + } + + bool isUnsafeImport(LibraryElement lib); + String suggestionFor(LibraryElement lib); +} + +class ServerScopeImportsVisitor extends ScopeImportsVisitor { + ServerScopeImportsVisitor(super.rule, super.treeNode) : super(edgeType: EdgeType.server); + + @override + bool isUnsafeImport(LibraryElement lib) { + return lib.identifier == 'package:jaspr/client.dart' || + lib.identifier == 'package:web/web.dart' || + lib.identifier == 'dart:js_interop' || + lib.identifier == 'dart:js_interop_unsafe' || + lib.identifier == 'dart:html' || + lib.identifier == 'dart:js' || + lib.identifier == 'dart:js_util' || + lib.identifier.startsWith('package:flutter/'); + } + + @override + String suggestionFor(LibraryElement lib) { + if (lib.identifier == 'package:jaspr/client.dart') { + return "Try using 'package:jaspr/jaspr.dart' instead."; + } else if (lib.identifier == 'package:web/web.dart') { + return "Try using 'package:universal_web/web.dart' instead."; + } else if (lib.identifier == 'dart:js_interop') { + return "Try using 'package:universal_web/js_interop.dart' instead."; + } + return 'Try using a platform-independent library or conditional import.'; + } +} + +class ClientScopeImportsVisitor extends ScopeImportsVisitor { + ClientScopeImportsVisitor(super.rule, super.treeNode, this.allowFlutterLibsInClient) + : super(edgeType: EdgeType.client); + + final bool allowFlutterLibsInClient; + + @override + bool isUnsafeImport(LibraryElement lib) { + return lib.identifier == 'package:jaspr/server.dart' || + lib.identifier == 'package:jaspr_content/jaspr_content.dart' || + (!allowFlutterLibsInClient && lib.identifier == 'dart:io') || + (!allowFlutterLibsInClient && lib.identifier.startsWith('package:flutter/')) || + lib.identifier == 'dart:ffi' || + lib.identifier == 'dart:isolate' || + lib.identifier == 'dart:mirrors'; + } + + @override + String suggestionFor(LibraryElement lib) { + if (lib.identifier == 'package:jaspr/server.dart') { + return "Try using 'package:jaspr/jaspr.dart' instead."; + } + return 'Try moving this out of the client scope or use a conditional import.'; + } +} + +class SimpleDiagnosticMessage implements DiagnosticMessage { + SimpleDiagnosticMessage({ + required this.filePath, + required this.offset, + required this.length, + required this.message, + }); + + @override + final String filePath; + + @override + final int offset; + @override + final int length; + + final String message; + + @override + String messageText({required bool includeUrl}) => message; + + @override + String? get url => null; +} diff --git a/packages/jaspr_lints/lib/src/utils/logging.dart b/packages/jaspr_lints/lib/src/utils/logging.dart new file mode 100644 index 000000000..a62c0455e --- /dev/null +++ b/packages/jaspr_lints/lib/src/utils/logging.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; + +final logFile = getLogFile(); + +void log(String message) { + final time = DateTime.now(); + final timeStr = + '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}:' + '${time.second.toString().padLeft(2, '0')}'; + logFile.writeAsStringSync('$timeStr $message\n', mode: FileMode.append, flush: true); +} + +File getLogFile() { + Directory dir; + if (homeDir case final homeDir?) { + dir = Directory(path.join(homeDir.path, '.jaspr')).absolute; + } else { + dir = Directory.systemTemp.createTempSync('jaspr_plugin_log_'); + } + return File(path.join(dir.path, 'plugin_log.log'))..createSync(recursive: true); +} + +/// Return the user's home directory for the current platform. +Directory? get homeDir { + final envKey = Platform.operatingSystem == 'windows' ? 'APPDATA' : 'HOME'; + final home = Platform.environment[envKey] ?? '.'; + + final dir = Directory(home).absolute; + return dir.existsSync() ? dir : null; +} diff --git a/packages/jaspr_lints/lib/src/utils/scope_tree.dart b/packages/jaspr_lints/lib/src/utils/scope_tree.dart new file mode 100644 index 000000000..cc89c383d --- /dev/null +++ b/packages/jaspr_lints/lib/src/utils/scope_tree.dart @@ -0,0 +1,409 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:path/path.dart' as path; +import 'logging.dart'; + +class ScopeTree { + ScopeTree(); + + bool dirty = true; + + ScopeTreeNode analyzeLibrary(LibraryElement library) { + final node = inspectLibrary(library); + dirty |= node.dirty; + node.analyzeChildren(); + + return node; + } + + Map nodes = {}; + + ScopeTreeNode inspectLibrary(LibraryElement library) { + final path = library.firstFragment.source.fullName; + + if (library.isInSdk || + library.identifier.startsWith('package:jaspr/') || + library.identifier.startsWith('package:web/') || + library.identifier.startsWith('package:universal_web/') || + library.identifier.startsWith('package:flutter/')) { + // Skip SDK and framework libraries. + return nodes[path] ??= ScopeTreeNode(library, this); + } + + if (nodes[path] case final node?) { + if (node.library == library) { + return node; + } + + node.library = library; + node.dirty = true; + node.analyzeSelf(); + + return node; + } + + final node = ScopeTreeNode(library, this); + nodes[path] = node; + node.analyzeSelf(); + + return node; + } + + void writeScopes(String rootPath) { + final file = File(path.join(rootPath, '.dart_tool', 'jaspr', 'scopes.json')); + if (!dirty && file.existsSync()) { + return; + } + file.createSync(recursive: true); + file.writeAsStringSync(jsonEncode(buildScopes())); + dirty = false; + } + + Map buildScopes() { + final serverScopes = >{}; + final clientScopes = >{}; + + void setScopes(ScopeTreeNode node, Set parentServerScopes, Set parentClientScopes) { + final nodeServerScopes = serverScopes[node] ??= {}; + final nodeClientScopes = clientScopes[node] ??= {}; + + bool didSetScopes = false; + + if (node.serverScopeLocation case final location? when !nodeServerScopes.contains(location)) { + nodeServerScopes.add(location); + didSetScopes = true; + } + + if (node.clientScopeLocation case final location? when !nodeClientScopes.contains(location)) { + nodeClientScopes.add(location); + didSetScopes = true; + } + + if (parentServerScopes.isNotEmpty && parentServerScopes.any((s) => !nodeServerScopes.contains(s))) { + nodeServerScopes.addAll(parentServerScopes); + didSetScopes = true; + } + + if (parentClientScopes.isNotEmpty && parentClientScopes.any((s) => !nodeClientScopes.contains(s))) { + nodeClientScopes.addAll(parentClientScopes); + didSetScopes = true; + } + + if (didSetScopes) { + for (final edge in node.childEdges) { + setScopes( + edge.childNode, + edge.type != EdgeType.client ? nodeServerScopes : {}, + edge.type != EdgeType.server ? nodeClientScopes : {}, + ); + } + } + } + + for (final node in nodes.values) { + setScopes(node, {}, {}); + } + + final output = {}; + + for (final libraryPath in nodes.keys) { + final node = nodes[libraryPath]!; + + if (node.components.isEmpty) { + continue; // Skip libraries without components. + } + + final nodeServerScopes = serverScopes[node] ??= {}; + final nodeClientScopes = clientScopes[node] ??= {}; + + output[libraryPath] = { + 'components': node.components.map((e) => e.toJson()).toList(), + if (nodeServerScopes.isNotEmpty) + 'serverScopeRoots': [ + for (final location in nodeServerScopes) location.toJson(), + ], + if (nodeClientScopes.isNotEmpty) + 'clientScopeRoots': [ + for (final location in nodeClientScopes) location.toJson(), + ], + }; + } + + return output; + } +} + +// A node in the scope tree, identified by a library. +class ScopeTreeNode { + ScopeTreeNode(this.library, this.tree); + + LibraryElement library; + final ScopeTree tree; + + bool dirty = true; + + NodeLocation? serverScopeLocation; + NodeLocation? clientScopeLocation; + + final List components = []; + + final List parentEdges = []; + final List childEdges = []; + + void analyzeSelf() { + if (!dirty) return; + + final path = library.firstFragment.source.fullName; + + components.clear(); + serverScopeLocation = null; + clientScopeLocation = null; + + for (final clazz in library.classes) { + final location = clazz.firstFragment.libraryFragment.lineInfo.getLocation( + clazz.firstFragment.nameOffset ?? clazz.firstFragment.offset, + ); + if (clazz.isComponent) { + final clazzLocation = NodeLocation( + path, + clazz.name ?? '', + location.lineNumber, + location.columnNumber, + clazz.name?.length ?? 1, + ); + components.add(clazzLocation); + if (clazz.hasClientAnnotation) { + clientScopeLocation = clazzLocation; + } + } + } + + if (path.endsWith('.server.dart')) { + serverScopeLocation = findMainFunction(); + } + + if (path.endsWith('.client.dart')) { + clientScopeLocation = findMainFunction(); + } + } + + NodeLocation? findMainFunction() { + final mainFunction = library.topLevelFunctions.where((e) => e.name == 'main').firstOrNull?.firstFragment; + final mainLocation = mainFunction?.libraryFragment.lineInfo.getLocation( + mainFunction.nameOffset ?? mainFunction.offset, + ); + return NodeLocation( + library.firstFragment.source.fullName, + 'main', + mainLocation?.lineNumber ?? 0, + mainLocation?.columnNumber ?? 0, + 4, + ); + } + + void analyzeChildren() { + if (!dirty) return; + + if (childEdges.isNotEmpty) { + for (final edge in childEdges) { + edge.childNode.parentEdges.removeWhere((e) => e.parentNode.library.identifier == library.identifier); + } + } + childEdges.clear(); + + final dependencies = resolveDependencies(library); + + for (final (:lib, :dir, :config, :type) in dependencies) { + final child = tree.inspectLibrary(lib); + final edge = ScopeTreeEdge(this, child, dir, config, type); + childEdges.add(edge); + child.parentEdges.add(edge); + } + + for (final edge in childEdges) { + edge.childNode.analyzeChildren(); + } + + dirty = false; + } + + List<({LibraryElement lib, UriBasedDirective dir, Configuration? config, EdgeType type})> resolveDependencies( + LibraryElement library, + ) { + const frameworkPackages = [ + 'jaspr', + 'jaspr_content', + 'jaspr_router', + 'jaspr_flutter_embed', + 'web', + 'universal_web', + 'flutter', + ]; + if (library.isInSdk || frameworkPackages.any((p) => library.identifier.startsWith('package:$p/'))) { + // Skip SDK and framework libraries. + return []; + } + if (library.identifier.endsWith('.server.options.dart') || library.identifier.endsWith('.client.options.dart')) { + // Skip generated option files. + return []; + } + + final result = library.session.getParsedLibraryByElement(library); + if (result is! ParsedLibraryResult) { + log('[Warning] ImportTreeNode.resolveDependencies: Failed to parse library ${library.uri}'); + return []; + } + + final imports = library.fragments.expand((f) => f.libraryImports).toList(); + final exports = library.fragments.expand((f) => f.libraryExports).toList(); + + LibraryElement? getBaseLibraryForDirective(NamespaceDirective directive) { + bool matchesUri(ElementDirective d) => switch (d.uri) { + final DirectiveUriWithRelativeUriString uri => uri.relativeUriString == directive.uri.stringValue, + _ => false, + }; + if (directive is ImportDirective) { + return directive.libraryImport?.importedLibrary ?? imports.where(matchesUri).firstOrNull?.importedLibrary; + } else if (directive is ExportDirective) { + return directive.libraryExport?.exportedLibrary ?? exports.where(matchesUri).firstOrNull?.exportedLibrary; + } + return null; + } + + LibraryElement? resolveLibraryFromUri(String? uri) { + if (uri == null) return null; + final absolutePath = library.session.uriConverter.uriToPath(library.uri.resolve(uri)); + if (absolutePath == null) return null; + final result = library.session.getParsedLibrary(absolutePath); + if (result is ParsedLibraryResult) { + return result.units.first.unit.declaredFragment?.element; + } + return null; + } + + final dependencies = <({LibraryElement lib, UriBasedDirective dir, Configuration? config, EdgeType type})>[]; + + for (final unit in result.units) { + for (final directive in unit.unit.directives) { + if (directive is NamespaceDirective) { + final configuration = directive.configurations; + + const clientLibs = ['js_interop', 'js_interop_unsafe', 'html', 'js', 'js_util']; + const serverLibs = ['io', 'ffi', 'isolate', 'mirrors']; + + final libConfigurations = configuration.where( + (c) => + c.name.components.length == 3 && + c.name.components[0].name == 'dart' && + c.name.components[1].name == 'library', + ); + + final clientConfiguration = libConfigurations + .where((c) => clientLibs.contains(c.name.components.last.name)) + .firstOrNull; + final serverConfiguration = libConfigurations + .where((c) => serverLibs.contains(c.name.components.last.name)) + .firstOrNull; + + final baseLib = getBaseLibraryForDirective(directive); + if (baseLib == null) { + log( + '[Warning] ImportTreeNode.resolveDependencies: Could not resolve base library for ${directive.uri.stringValue} in ${library.identifier}', + ); + } + + if (clientConfiguration == null && serverConfiguration == null) { + // This is a general import (or with unsupported configurations), add to general dependencies + if (baseLib != null) { + dependencies.add((lib: baseLib, dir: directive, config: null, type: EdgeType.general)); + } + continue; + } + + if (clientConfiguration != null) { + final clientLib = resolveLibraryFromUri(clientConfiguration.uri.stringValue); + if (clientLib != null) { + dependencies.add((lib: clientLib, dir: directive, config: clientConfiguration, type: EdgeType.client)); + } else { + log( + '[Warning] ImportTreeNode.resolveDependencies: Could not resolve client library for ${clientConfiguration.uri.stringValue} in ${library.identifier}', + ); + } + } else { + // On the client, the base import is used. + if (baseLib != null) { + dependencies.add((lib: baseLib, dir: directive, config: null, type: EdgeType.client)); + } + } + + if (serverConfiguration != null) { + final serverLib = resolveLibraryFromUri(serverConfiguration.uri.stringValue); + if (serverLib != null) { + dependencies.add((lib: serverLib, dir: directive, config: serverConfiguration, type: EdgeType.server)); + } else { + log( + '[Warning] Could not resolve server library for ${serverConfiguration.uri.stringValue} in ${library.identifier}', + ); + } + } else { + // On the server, the base import is used. + if (baseLib != null) { + dependencies.add((lib: baseLib, dir: directive, config: null, type: EdgeType.server)); + } + } + } + } + } + + return dependencies; + } +} + +// An edge in the scope tree, representing a single import or export directive. +class ScopeTreeEdge { + ScopeTreeEdge(this.parentNode, this.childNode, this.directive, this.configuration, this.type); + + final ScopeTreeNode parentNode; + final ScopeTreeNode childNode; + final UriBasedDirective directive; + final Configuration? configuration; + final EdgeType type; +} + +enum EdgeType { general, client, server } + +/// The location of a ast declaration. +class NodeLocation { + final String path; + final String name; + final int line; + final int character; + final int length; + + NodeLocation(this.path, this.name, this.line, this.character, this.length); + + Map toJson() { + return {'path': path, 'name': name, 'line': line, 'character': character, 'length': length}; + } +} + +extension on ClassElement { + bool get isComponent { + return allSupertypes.any( + (e) => + e.element.name == 'Component' && e.element.library.identifier == 'package:jaspr/src/framework/framework.dart', + ); + } + + bool get hasClientAnnotation { + return metadata.annotations.any( + (a) => + a.element?.name == 'client' && + a.element?.library?.identifier == 'package:jaspr/src/foundation/annotations.dart', + ); + } +} From e4e3331405976d7431770d6a54d920275d9523f5 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Fri, 27 Mar 2026 16:47:50 +0100 Subject: [PATCH 2/6] remove tooling-daemon command and add tests for lint rules --- .../jaspr_cli/lib/src/command_runner.dart | 6 +- .../src/commands/convert_html_command.dart | 257 ++++++- .../src/commands/tooling_daemon_command.dart | 33 - .../lib/src/domains/html_domain.dart | 222 ------ .../lib/src/domains/scopes_domain.dart | 654 ------------------ .../jaspr_cli/test/domains/html_test.dart | 113 --- .../jaspr_cli/test/domains/scopes_test.dart | 349 ---------- .../commands/convert_html_command_test.dart | 148 +++- .../lib/src/rules/unsafe_imports_rule.dart | 7 +- .../jaspr_lints/lib/src/utils/scope_tree.dart | 24 +- packages/jaspr_lints/pubspec.yaml | 4 + packages/jaspr_lints/test/jaspr_package.dart | 69 ++ .../prefer_html_components_rule_test.dart | 48 ++ .../rules/prefer_styles_getter_rule_test.dart | 73 ++ .../rules/sort_children_last_rule_test.dart | 45 ++ .../test/rules/styles_ordering_rule_test.dart | 78 +++ .../test/rules/unsafe_imports_rule_test.dart | 447 ++++++++++++ pubspec.lock | 16 + 18 files changed, 1169 insertions(+), 1424 deletions(-) delete mode 100644 packages/jaspr_cli/lib/src/commands/tooling_daemon_command.dart delete mode 100644 packages/jaspr_cli/lib/src/domains/html_domain.dart delete mode 100644 packages/jaspr_cli/lib/src/domains/scopes_domain.dart delete mode 100644 packages/jaspr_cli/test/domains/html_test.dart delete mode 100644 packages/jaspr_cli/test/domains/scopes_test.dart create mode 100644 packages/jaspr_lints/test/jaspr_package.dart create mode 100644 packages/jaspr_lints/test/rules/prefer_html_components_rule_test.dart create mode 100644 packages/jaspr_lints/test/rules/prefer_styles_getter_rule_test.dart create mode 100644 packages/jaspr_lints/test/rules/sort_children_last_rule_test.dart create mode 100644 packages/jaspr_lints/test/rules/styles_ordering_rule_test.dart create mode 100644 packages/jaspr_lints/test/rules/unsafe_imports_rule_test.dart diff --git a/packages/jaspr_cli/lib/src/command_runner.dart b/packages/jaspr_cli/lib/src/command_runner.dart index f3185b65a..595c0256c 100644 --- a/packages/jaspr_cli/lib/src/command_runner.dart +++ b/packages/jaspr_cli/lib/src/command_runner.dart @@ -16,7 +16,6 @@ import 'commands/doctor_command.dart'; import 'commands/install_skills_command.dart'; import 'commands/migrate_command.dart'; import 'commands/serve_command.dart'; -import 'commands/tooling_daemon_command.dart'; import 'commands/update_command.dart'; import 'helpers/analytics.dart'; import 'utils.dart'; @@ -37,15 +36,14 @@ class JasprCommandRunner extends CompletionCommandRunner { argParser.addFlag('disable-analytics', negatable: false, help: 'Disable anonymous analytics.'); addCommand(CreateCommand()); addCommand(ServeCommand()); + addCommand(DaemonCommand()); addCommand(BuildCommand()); - addCommand(ConvertHtmlCommand()); addCommand(CleanCommand()); addCommand(UpdateCommand()); addCommand(DoctorCommand()); addCommand(MigrateCommand()); addCommand(InstallSkillsCommand()); - addCommand(DaemonCommand()); - addCommand(ToolingDaemonCommand()); + addCommand(ConvertHtmlCommand()); } final Logger _logger = Logger(); diff --git a/packages/jaspr_cli/lib/src/commands/convert_html_command.dart b/packages/jaspr_cli/lib/src/commands/convert_html_command.dart index e0c063e7b..22a827cb4 100644 --- a/packages/jaspr_cli/lib/src/commands/convert_html_command.dart +++ b/packages/jaspr_cli/lib/src/commands/convert_html_command.dart @@ -1,17 +1,22 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:html/dom.dart'; +import 'package:html/parser.dart'; import 'package:http/http.dart' as http; -import '../domains/html_domain.dart'; +import '../html_spec.dart'; import '../logging.dart'; import 'base_command.dart'; class ConvertHtmlCommand extends BaseCommand { ConvertHtmlCommand({super.logger}) { argParser + ..addOption('html', help: 'The HTML to convert, as a json-encoded string.') ..addOption('file', abbr: 'f', help: 'The HTML file to convert.') ..addOption('url', abbr: 'u', help: 'The URL to fetch HTML from.') - ..addOption('query', abbr: 'q', help: 'A CSS selector to narrow down the conversion.'); + ..addOption('query', abbr: 'q', help: 'A CSS selector to narrow down the conversion.') + ..addFlag('json', help: 'Output the result as JSON.'); } @override @@ -25,37 +30,243 @@ class ConvertHtmlCommand extends BaseCommand { @override Future runCommand() async { + var html = argResults?.option('html'); final file = argResults?.option('file'); final url = argResults?.option('url'); final query = argResults?.option('query'); + final json = argResults?.flag('json') ?? false; - String html; - if (file != null && url == null) { - final f = File(file); - if (!f.existsSync()) { - logger.write('File not found: $file', level: Level.error); - return 1; - } - html = f.readAsStringSync(); - } else if (file == null && url != null) { - try { - final response = await http.get(Uri.parse(url)); - if (response.statusCode != 200) { - logger.write('Failed to fetch URL: $url (Status: ${response.statusCode})', level: Level.error); + if (html == null) { + if (file != null && url == null) { + final f = File(file); + if (!f.existsSync()) { + logger.write('File not found: $file', level: Level.error); + return 1; + } + html = f.readAsStringSync(); + } else if (file == null && url != null) { + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode != 200) { + logger.write('Failed to fetch URL: $url (Status: ${response.statusCode})', level: Level.error); + return 1; + } + html = response.body; + } catch (e) { + logger.write('Error fetching URL: $e', level: Level.error); return 1; } - html = response.body; - } catch (e) { - logger.write('Error fetching URL: $e', level: Level.error); + } else { + logger.write('Either --html, --file or --url must be provided.', level: Level.error); return 1; } - } else { - logger.write('Either --file or --url must be provided.', level: Level.error); - return 1; } - final result = await HtmlDomain.convertHtml(html, query); - logger.write(result); + final result = convertHtml(html, query); + if (json) { + logger.write(jsonEncode({'result': result})); + } else { + logger.write(result); + } return 0; } + + String convertHtml(String html, String? query) { + final parser = HtmlParser(html); + final Node document; + if (html.startsWith(' nodes = document.children; + + if (query != null) { + nodes = switch (document) { + Document() => document.querySelectorAll(query), + DocumentFragment() => document.querySelectorAll(query), + _ => [], + }; + } else if (html.startsWith(' _convertNode(node, ' ')).where((c) => c.trim().isNotEmpty).join(',\n')}\n])'; + } + + String _convertNode(Node? node, String indent) { + if (node == null) { + return ''; + } + if (node is Text) { + final text = node.text; + if (text.trim().isEmpty) { + return ''; + } + return '$indent.text(${_escapeString(text)})'; + } else if (node is Element) { + final tagName = node.localName; + final attrs = node.attributes; + final children = node.nodes; + + final spec = elementSpecs[tagName] as Map?; + + if (spec == null) { + final attrsString = attrs.isEmpty + ? '' + : ', attributes: {${attrs.entries.map((e) => "'${e.key}': ${_escapeString(e.value)}").join(', ')}}'; + + final childrenString = children.isEmpty + ? '' + : ', children: [\n${children.map((c) => _convertNode(c, '$indent ')).where((c) => c.trim().isNotEmpty).join(',\n')}\n$indent]'; + + return '${indent}Component.element(tag: \'$tagName\'$attrsString$childrenString)'; + } + + final specAttributes = spec['attributes'] as Map?; + + String? idString; + String? classString; + final paramStrings = []; + final attrStrings = []; + + for (final MapEntry(:key, :value) in attrs.entries) { + if (key == 'class') { + classString = value; + } else if (key == 'id') { + idString = value; + } else { + if (specAttributes?[key] case final Map attrSpec) { + final attrName = (attrSpec['name'] ?? key) as String; + final attrType = attrSpec['type'] as String; + + if (attrType == 'string') { + paramStrings.add('$attrName: ${_escapeString(value)}'); + continue; + } + if (attrType == 'boolean') { + paramStrings.add('$attrName: true'); + continue; + } + if (attrType.startsWith('enum:')) { + final enumName = attrType.substring(5); + final enumSpec = htmlSpec['enums']![enumName] as Map; + + final enumValue = (enumSpec['values'] as Map).entries + .where( + (e) => ((e.value as Map?)?['value'] ?? e.key) == value, + ) + .firstOrNull; + if (enumValue != null) { + paramStrings.add('$attrName: $enumName.${enumValue.key}'); + continue; + } + } + } + attrStrings.add("'$key': ${_escapeString(value)}"); + } + } + + var result = '$indent${spec['name']}('; + + if (idString != null) { + result += 'id: ${_escapeString(idString)}, '; + } + + if (classString != null) { + result += 'classes: ${_escapeString(classString)}, '; + } + + if (paramStrings.isNotEmpty) { + for (final param in paramStrings) { + result += '$param, '; + } + } + + if (attrStrings.isNotEmpty) { + result += 'attributes: {'; + var isFirst = true; + for (final attrString in attrStrings) { + if (!isFirst) { + result += ', '; + } + isFirst = false; + result += attrString; + } + result += '}, '; + } + + final contentParam = specAttributes?.entries + .map((e) => (key: e.key, value: e.value as Map)) + .where((e) => e.value['type'] == 'content') + .firstOrNull; + + if (contentParam != null && children.isNotEmpty) { + final contentParamName = contentParam.value['name'] as String? ?? contentParam.key; + result += '$contentParamName: ${_escapeString(node.innerHtml)}'; + } + + if (contentParam == null && spec['self_closing'] != true) { + if (children.isEmpty) { + result += '[]'; + } else { + result += '[\n'; + for (final child in children) { + final childHtml = _convertNode(child, '$indent '); + if (childHtml.trim().isEmpty) { + continue; + } + result += '$childHtml,\n'; + } + result += '$indent]'; + } + } else { + if (result.endsWith(', ')) { + result = result.substring(0, result.length - 2); + } + } + + result += ')'; + + return result; + } else if (node is Comment) { + final data = node.data?.trimLeft(); + if (data == null || data.isEmpty) { + return ''; + } + return '$indent// $data'; + } else { + return ''; + } + } + + String _escapeString(String input) { + final isMultiLine = input.contains('\n'); + var escaped = input.replaceAll(r'\', r'\\').replaceAll(r'$', r'\$'); + if (isMultiLine) { + escaped = escaped.replaceAll("'''", r"\'\'\'"); + return "'''$escaped'''"; + } else { + escaped = escaped.replaceAll("'", r"\'"); + return "'$escaped'"; + } + } } + +final elementSpecs = (() { + final config = {}; + for (final group in htmlSpec['tags']!.values) { + for (final entry in group.entries) { + final name = entry.key; + final data = entry.value as Map; + final tag = data['tag'] as String? ?? name; + config[tag] = {...data, 'name': name}; + } + } + return config; +})(); diff --git a/packages/jaspr_cli/lib/src/commands/tooling_daemon_command.dart b/packages/jaspr_cli/lib/src/commands/tooling_daemon_command.dart deleted file mode 100644 index fc012404e..000000000 --- a/packages/jaspr_cli/lib/src/commands/tooling_daemon_command.dart +++ /dev/null @@ -1,33 +0,0 @@ -import '../daemon/logger.dart'; -import '../domains/html_domain.dart'; -import '../domains/scopes_domain.dart'; -import '../helpers/daemon_helper.dart'; -import 'base_command.dart'; - -class ToolingDaemonCommand extends BaseCommand with DaemonHelper { - ToolingDaemonCommand() : super(logger: DaemonLogger()); - - @override - String get description => 'Start the Jaspr tooling daemon.'; - - @override - String get name => 'tooling-daemon'; - - @override - String get category => 'Tooling'; - - @override - bool get hidden => true; - - @override - bool get verbose => true; - - @override - Future runCommand() async { - return runWithDaemon((daemon) async { - daemon.registerDomain(ScopesDomain(daemon, logger)); - daemon.registerDomain(HtmlDomain(daemon, logger)); - return 0; - }); - } -} diff --git a/packages/jaspr_cli/lib/src/domains/html_domain.dart b/packages/jaspr_cli/lib/src/domains/html_domain.dart deleted file mode 100644 index 7065d29fb..000000000 --- a/packages/jaspr_cli/lib/src/domains/html_domain.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'dart:async'; - -import 'package:html/dom.dart'; -import 'package:html/parser.dart'; - -import '../daemon/daemon.dart'; -import '../daemon/domain.dart'; -import '../html_spec.dart'; -import '../logging.dart'; - -class HtmlDomain extends Domain { - HtmlDomain(Daemon daemon, this.logger) : super(daemon, 'html') { - registerHandler('convert', _convertHtml); - } - - final Logger logger; - - static Future convertHtml(String html, String? query) async { - final parser = HtmlParser(html); - final Node document; - if (html.startsWith(' nodes = document.children; - - if (query != null) { - nodes = switch (document) { - Document() => document.querySelectorAll(query), - DocumentFragment() => document.querySelectorAll(query), - _ => [], - }; - } else if (html.startsWith(' _convertNode(node, ' ')).where((c) => c.trim().isNotEmpty).join(',\n')}\n])'; - } - - Future _convertHtml(Map params) async { - final html = params['html'] as String; - final query = params['query'] as String?; - return convertHtml(html, query); - } - - static String _convertNode(Node? node, String indent) { - if (node == null) { - return ''; - } - if (node is Text) { - final text = node.text; - if (text.trim().isEmpty) { - return ''; - } - return '$indent.text(${_escapeString(text)})'; - } else if (node is Element) { - final tagName = node.localName; - final attrs = node.attributes; - final children = node.nodes; - - final spec = elementSpecs[tagName] as Map?; - - if (spec == null) { - final attrsString = attrs.isEmpty - ? '' - : ', attributes: {${attrs.entries.map((e) => "'${e.key}': ${_escapeString(e.value)}").join(', ')}}'; - - final childrenString = children.isEmpty - ? '' - : ', children: [\n${children.map((c) => _convertNode(c, '$indent ')).where((c) => c.trim().isNotEmpty).join(',\n')}\n$indent]'; - - return '${indent}Component.element(tag: \'$tagName\'$attrsString$childrenString)'; - } - - final specAttributes = spec['attributes'] as Map?; - - String? idString; - String? classString; - final paramStrings = []; - final attrStrings = []; - - for (final MapEntry(:key, :value) in attrs.entries) { - if (key == 'class') { - classString = value; - } else if (key == 'id') { - idString = value; - } else { - if (specAttributes?[key] case final Map attrSpec) { - final attrName = (attrSpec['name'] ?? key) as String; - final attrType = attrSpec['type'] as String; - - if (attrType == 'string') { - paramStrings.add('$attrName: ${_escapeString(value)}'); - continue; - } - if (attrType == 'boolean') { - paramStrings.add('$attrName: true'); - continue; - } - if (attrType.startsWith('enum:')) { - final enumName = attrType.substring(5); - final enumSpec = htmlSpec['enums']![enumName] as Map; - - final enumValue = (enumSpec['values'] as Map).entries - .where( - (e) => ((e.value as Map?)?['value'] ?? e.key) == value, - ) - .firstOrNull; - if (enumValue != null) { - paramStrings.add('$attrName: $enumName.${enumValue.key}'); - continue; - } - } - } - attrStrings.add("'$key': ${_escapeString(value)}"); - } - } - - var result = '$indent${spec['name']}('; - - if (idString != null) { - result += 'id: ${_escapeString(idString)}, '; - } - - if (classString != null) { - result += 'classes: ${_escapeString(classString)}, '; - } - - if (paramStrings.isNotEmpty) { - for (final param in paramStrings) { - result += '$param, '; - } - } - - if (attrStrings.isNotEmpty) { - result += 'attributes: {'; - var isFirst = true; - for (final attrString in attrStrings) { - if (!isFirst) { - result += ', '; - } - isFirst = false; - result += attrString; - } - result += '}, '; - } - - final contentParam = specAttributes?.entries - .map((e) => (key: e.key, value: e.value as Map)) - .where((e) => e.value['type'] == 'content') - .firstOrNull; - - if (contentParam != null && children.isNotEmpty) { - final contentParamName = contentParam.value['name'] as String? ?? contentParam.key; - result += '$contentParamName: ${_escapeString(node.innerHtml)}'; - } - - if (contentParam == null && spec['self_closing'] != true) { - if (children.isEmpty) { - result += '[]'; - } else { - result += '[\n'; - for (final child in children) { - final childHtml = _convertNode(child, '$indent '); - if (childHtml.trim().isEmpty) { - continue; - } - result += '$childHtml,\n'; - } - result += '$indent]'; - } - } else { - if (result.endsWith(', ')) { - result = result.substring(0, result.length - 2); - } - } - - result += ')'; - - return result; - } else if (node is Comment) { - final data = node.data?.trimLeft(); - if (data == null || data.isEmpty) { - return ''; - } - return '$indent// $data'; - } else { - return ''; - } - } - - static String _escapeString(String input) { - final isMultiLine = input.contains('\n'); - var escaped = input.replaceAll(r'\', r'\\').replaceAll(r'$', r'\$'); - if (isMultiLine) { - escaped = escaped.replaceAll("'''", r"\'\'\'"); - return "'''$escaped'''"; - } else { - escaped = escaped.replaceAll("'", r"\'"); - return "'$escaped'"; - } - } -} - -final elementSpecs = (() { - final config = {}; - for (final group in htmlSpec['tags']!.values) { - for (final entry in group.entries) { - final name = entry.key; - final data = entry.value as Map; - final tag = data['tag'] as String? ?? name; - config[tag] = {...data, 'name': name}; - } - } - return config; -})(); diff --git a/packages/jaspr_cli/lib/src/domains/scopes_domain.dart b/packages/jaspr_cli/lib/src/domains/scopes_domain.dart deleted file mode 100644 index 88edd12b7..000000000 --- a/packages/jaspr_cli/lib/src/domains/scopes_domain.dart +++ /dev/null @@ -1,654 +0,0 @@ -import 'dart:async'; -import 'dart:io' as io; - -import 'package:analyzer/dart/analysis/analysis_context.dart'; -import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; -import 'package:analyzer/dart/analysis/results.dart'; -import 'package:analyzer/dart/analysis/session.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/file_system/file_system.dart'; -import 'package:analyzer/file_system/physical_file_system.dart'; -import 'package:collection/collection.dart'; -import 'package:glob/glob.dart'; -import 'package:glob/list_local_fs.dart'; -import 'package:path/path.dart' as path; -import 'package:watcher/watcher.dart'; -import 'package:yaml/yaml.dart'; - -import '../daemon/daemon.dart'; -import '../daemon/domain.dart'; -import '../logging.dart'; -import '../project.dart'; - -class ScopesDomain extends Domain { - ScopesDomain(Daemon daemon, this.logger) : super(daemon, 'scopes') { - registerHandler('register', registerScopes); - } - - final Logger logger; - AnalysisContextCollection? _collection; - final List> _watcherSubscriptions = []; - final Map _analysisStatus = {}; - final Map _inspectedData = {}; - - Future registerScopes( - Map params, { - ResourceProvider? resourceProvider, - }) async { - await _collection?.dispose(); - // ignore: avoid_function_literals_in_foreach_calls - _watcherSubscriptions.forEach((sub) => sub.cancel()); - _watcherSubscriptions.clear(); - _inspectedData.clear(); - - final folders = (params['folders'] as List).cast(); - final entryPaths = []; - final serverEntrypointGlob = Glob('**/*.server.dart'); - - var allowServerLibsInClient = false; - - for (final folder in folders) { - try { - final pubspecFile = io.File(path.join(folder, 'pubspec.yaml')); - if (!pubspecFile.existsSync()) { - logger.write('No pubspec.yaml found in $folder'); - continue; - } - - final pubspecYaml = loadYaml(pubspecFile.readAsStringSync()) as YamlMap; - if (pubspecYaml case {'jaspr': {'mode': 'server' || 'static'}}) { - // ok - } else { - logger.write('Scopes not available in client mode.'); - continue; - } - - if (pubspecYaml case {'jaspr': {'flutter': 'embedded' || 'plugins'}}) { - allowServerLibsInClient = true; - } - } catch (e) { - logger.write('Failed to read pubspec.yaml in $folder: $e'); - continue; - } - - final serverEntrypoints = serverEntrypointGlob.listSync(root: folder); - if (serverEntrypoints.isEmpty) { - logger.write('No server entrypoints found in $folder.'); - continue; - } - entryPaths.addAll(serverEntrypoints.map((e) => e.path)); - } - - if (entryPaths.isEmpty) { - return; - } - - _collection = AnalysisContextCollection( - includedPaths: entryPaths, - resourceProvider: resourceProvider ?? PhysicalResourceProvider.INSTANCE, - sdkPath: dartSdkDir, - ); - - if (resourceProvider == null) { - for (final context in _collection!.contexts) { - _watcherSubscriptions.add( - DirectoryWatcher(context.contextRoot.root.path).events.listen((event) { - final path = event.path; - logger.write('File changed: $path'); - - if (path.endsWith('.server.dart') && event.type != ChangeType.MODIFY) { - // Recreate all scopes if an entrypoint is added or removed. - registerScopes(params); - } else if (path.endsWith('.dart')) { - _reanalyze(path, entryPaths, allowServerLibsInClient); - } else if (path.endsWith('pubspec.yaml') || - path.endsWith('pubspec.lock') || - path.endsWith('package_config.json')) { - // Recreate all scopes if pubspec or package config changes. - registerScopes(params); - } - }), - ); - } - } - - for (final context in _collection!.contexts) { - analyze(context, entryPaths, allowServerLibsInClient); - } - } - - void _reanalyze(String path, List entryPaths, bool allowServerLibsInClient) { - for (final context in _collection!.contexts) { - context.changeFile(path); - analyze(context, entryPaths, allowServerLibsInClient, true); - } - } - - Future analyze( - AnalysisContext context, - List entryPaths, - bool allowServerLibsInClient, [ - bool awaitPendingChanges = false, - ]) async { - final rootPath = context.contextRoot.root.path; - - final targets = entryPaths.where((e) => e.startsWith(rootPath)).toList(); - if (targets.isEmpty) { - return; - } - - try { - if (awaitPendingChanges) { - final sw = Stopwatch()..start(); - await context.applyPendingFileChanges(); - sw.stop(); - logger.write('Applied pending changes in ${sw.elapsedMilliseconds}ms'); - } - - logger.write('Analyzing $rootPath...'); - - _analysisStatus[context] = true; - emitStatus(); - - final sw = Stopwatch()..start(); - - final results = await [ - for (final target in targets) context.currentSession.getResolvedLibrary(target), - ].wait; - - sw.stop(); - - final libraries = []; - - for (final result in results) { - if (result is! ResolvedLibraryResult) { - final target = targets[results.indexOf(result)]; - logger.write('Failed to resolve "$target" in "$rootPath"'); - continue; - } - libraries.add(result.element); - } - - logger.write('Resolved ${libraries.length} libraries in "$rootPath" in ${sw.elapsedMilliseconds}ms'); - - if (libraries.isEmpty) { - return; - } - - final inspectData = await InspectData.analyze(libraries, allowServerLibsInClient, logger); - _inspectedData[context] = inspectData; - emitScopes(); - } on InconsistentAnalysisException catch (_) { - logger.write('Skipping inconsistent analysis for $rootPath'); - } catch (e) { - logger.write('Error analyzing $rootPath: $e'); - } finally { - _analysisStatus[context] = false; - emitStatus(); - } - } - - void emitScopes() { - final allLibraries = _inspectedData.values.expand((data) => data.libraries.keys).toSet(); - - final output = {}; - - for (final libraryPath in allLibraries) { - final components = {}; - final clientScopeRoots = {}; - final serverScopeRoots = {}; - - for (final data in _inspectedData.values) { - if (data.libraries.containsKey(libraryPath)) { - final item = data.libraries[libraryPath]!; - - components.addAll(item.components.map((e) => e.name)); - clientScopeRoots.addAll({ - for (final target in item.clientScopeRoots) '${target.path}:${target.name}': target, - }); - serverScopeRoots.addAll({ - for (final target in item.serverScopeRoots) '${target.path}:${target.name}': target, - }); - } - } - - final invalidDependencies = {}; - - for (final data in _inspectedData.values) { - if (data.libraries.containsKey(libraryPath)) { - final item = data.libraries[libraryPath]!; - - for (final c in item.children) { - if ((clientScopeRoots.isNotEmpty && c.invalidOnClient != null) || - (serverScopeRoots.isNotEmpty && c.invalidOnServer != null)) { - final uri = c.item.library.uri.toString(); - invalidDependencies[uri] = c; - } - } - } - } - - if (components.isEmpty && invalidDependencies.isEmpty) { - continue; // Skip libraries without components or invalid dependencies - } - - output[libraryPath] = { - if (components.isNotEmpty) 'components': components.toList(), - if (components.isNotEmpty && clientScopeRoots.isNotEmpty) - 'clientScopeRoots': clientScopeRoots.values.map((e) => e.toJson()).toList(), - if (components.isNotEmpty && serverScopeRoots.isNotEmpty) - 'serverScopeRoots': serverScopeRoots.values.map((e) => e.toJson()).toList(), - if (invalidDependencies.isNotEmpty) - 'invalidDependencies': [ - for (final entry in invalidDependencies.entries) - { - 'uri': entry.key, - if (clientScopeRoots.isNotEmpty && entry.value.invalidOnClient != null) - 'invalidOnClient': entry.value.invalidOnClient!.toJson(), - if (serverScopeRoots.isNotEmpty && entry.value.invalidOnServer != null) - 'invalidOnServer': entry.value.invalidOnServer!.toJson(), - }, - ], - }; - } - - sendEvent('scopes.result', output); - } - - void emitStatus() { - final output = {}; - - for (final context in _collection!.contexts) { - final status = _analysisStatus[context] ?? false; - output[context.contextRoot.root.path] = status; - } - - sendEvent('scopes.status', output); - } - - @override - void dispose() { - _collection?.dispose(); - // ignore: avoid_function_literals_in_foreach_calls - _watcherSubscriptions.forEach((sub) => sub.cancel()); - super.dispose(); - } -} - -class InspectData { - InspectData._(this.allowServerLibsInClient, this.logger); - static Future analyze( - List libraries, - bool allowServerLibsInClient, - Logger logger, - ) async { - final inspectData = InspectData._(allowServerLibsInClient, logger); - - for (final root in libraries) { - final mainFunction = root.topLevelFunctions.where((e) => e.name == 'main').firstOrNull?.firstFragment; - final mainLocation = mainFunction?.libraryFragment.lineInfo.getLocation( - mainFunction.nameOffset ?? mainFunction.offset, - ); - final mainTarget = InspectTarget( - root.firstFragment.source.fullName, - 'main', - mainLocation?.lineNumber ?? 0, - mainLocation?.columnNumber ?? 0, - ); - - final data = await inspectData.inspectLibrary(root, null, {}, {mainTarget}); - await data.analyzeChildren(); - } - return inspectData; - } - - final bool allowServerLibsInClient; - final Logger logger; - Map libraries = {}; - - Future inspectLibrary( - LibraryElement library, - InspectDataItem? parent, [ - Set clientScopeRoots = const {}, - Set serverScopeRoots = const {}, - ]) async { - final path = library.firstFragment.source.fullName; - - if (library.isInSdk || - library.identifier.startsWith('package:jaspr/') || - library.identifier.startsWith('package:web/') || - library.identifier.startsWith('package:flutter/')) { - // Skip SDK and framework libraries. - return libraries[path] ??= InspectDataItem(library, parent, this); - } - - if (libraries.containsKey(path)) { - final data = libraries[path]!; - final bool hasChangedScopes = - clientScopeRoots.any((e) => !data.clientScopeRoots.contains(e)) || - serverScopeRoots.any((e) => !data.serverScopeRoots.contains(e)); - if (hasChangedScopes) { - data.clientScopeRoots.addAll(clientScopeRoots); - data.serverScopeRoots.addAll(serverScopeRoots); - - for (final InspectItemDependency(:item, :onClient, :onServer) in data.children) { - await inspectLibrary(item.library, data, onClient ? clientScopeRoots : {}, onServer ? serverScopeRoots : {}); - } - } - - return data; - } - - final data = InspectDataItem(library, parent, this); - data.clientScopeRoots.addAll(clientScopeRoots); - data.serverScopeRoots.addAll(serverScopeRoots); - - libraries[path] = data; - - for (final clazz in library.classes) { - final location = clazz.firstFragment.libraryFragment.lineInfo.getLocation( - clazz.firstFragment.nameOffset ?? clazz.firstFragment.offset, - ); - final target = InspectTarget(path, clazz.name ?? '', location.lineNumber, location.columnNumber); - if (isComponent(clazz)) { - data.components.add(target); - if (hasClientAnnotation(clazz)) { - data.clientScopeRoots.add(target); - } - } - } - - return data; - } - - bool isComponent(ClassElement clazz) { - return clazz.allSupertypes.any( - (e) => - e.element.name == 'Component' && e.element.library.identifier == 'package:jaspr/src/framework/framework.dart', - ); - } - - bool hasClientAnnotation(ClassElement clazz) { - return clazz.metadata.annotations.any( - (a) => - a.element?.name == 'client' && - a.element?.library?.identifier == 'package:jaspr/src/foundation/annotations.dart', - ); - } - - bool isClientLib(LibraryElement lib) { - return lib.identifier == 'package:jaspr/client.dart' || - lib.identifier == 'package:web/web.dart' || - lib.identifier == 'dart:js_interop' || - lib.identifier == 'dart:js_interop_unsafe' || - lib.identifier == 'dart:html' || - lib.identifier == 'dart:js' || - lib.identifier == 'dart:js_util' || - lib.identifier.startsWith('package:flutter/'); - } - - bool isServerLib(LibraryElement lib) { - return lib.identifier == 'package:jaspr/server.dart' || - (!allowServerLibsInClient && lib.identifier == 'dart:io') || - lib.identifier == 'dart:ffi' || - lib.identifier == 'dart:isolate' || - lib.identifier == 'dart:mirrors'; - } -} - -class InspectDataItem { - InspectDataItem(this.library, this.parent, this.data); - - final LibraryElement library; - final InspectDataItem? parent; - final InspectData data; - - final List components = []; - final Set clientScopeRoots = {}; - final Set serverScopeRoots = {}; - - final List children = []; - - Future analyzeChildren() async { - final dependencies = await resolveDependencies(library); - - for (final (:lib, :dir, :onClient, :onServer) in dependencies) { - final child = await data.inspectLibrary( - lib, - this, - onClient ? clientScopeRoots : {}, - onServer ? serverScopeRoots : {}, - ); - final dep = InspectItemDependency(child, dir, onClient, onServer); - dep.invalidOnClient = onClient && data.isServerLib(lib) ? dir : null; - dep.invalidOnServer = onServer && data.isClientLib(lib) ? dir : null; - children.add(dep); - } - - for (final child in children) { - if (child.item.children.isNotEmpty) continue; // Already analyzed - await child.item.analyzeChildren(); - } - - for (final child in children) { - if (child.item.components.isEmpty) { - if (child.invalidOnClient == null) { - final childInvalidOnClient = child.onClient - ? child.item.children.map((c) => c.invalidOnClient).nonNulls.firstOrNull - : null; - if (childInvalidOnClient != null) { - child.invalidOnClient = child.dir.withTarget(childInvalidOnClient); - for (final c in child.item.children) { - c.invalidOnClient = null; - } - } - } - - if (child.invalidOnServer == null) { - final childInvalidOnServer = child.onServer - ? child.item.children.map((c) => c.invalidOnServer).nonNulls.firstOrNull - : null; - if (childInvalidOnServer != null) { - child.invalidOnServer = child.dir.withTarget(childInvalidOnServer); - for (final c in child.item.children) { - c.invalidOnServer = null; - } - } - } - } - } - } - - Future> resolveDependencies( - LibraryElement library, - ) async { - if (library.isInSdk || - library.identifier.startsWith('package:jaspr/') || - library.identifier.startsWith('package:web/') || - library.identifier.startsWith('package:flutter/')) { - // Skip SDK and framework libraries. - return []; - } - - final result = library.session.getParsedLibraryByElement(library); - if (result is! ParsedLibraryResult) { - data.logger.write('Tooling Daemon: Failed to parse library ${library.uri}', level: Level.warning); - return []; - } - - final imports = library.fragments.expand((f) => f.libraryImports).toList(); - final exports = library.fragments.expand((f) => f.libraryExports).toList(); - - LibraryElement? getBaseLibraryForDirective(NamespaceDirective directive) { - bool matchesUri(ElementDirective d) => switch (d.uri) { - final DirectiveUriWithRelativeUriString uri => uri.relativeUriString == directive.uri.stringValue, - _ => false, - }; - if (directive is ImportDirective) { - return directive.libraryImport?.importedLibrary ?? imports.where(matchesUri).firstOrNull?.importedLibrary; - } else if (directive is ExportDirective) { - return directive.libraryExport?.exportedLibrary ?? exports.where(matchesUri).firstOrNull?.exportedLibrary; - } - return null; - } - - Future resolveLibraryFromUri(String? uri) async { - if (uri == null) return null; - final absolutePath = library.session.uriConverter.uriToPath(library.uri.resolve(uri)); - if (absolutePath == null) return null; - final lib2 = await library.session.getResolvedLibrary(absolutePath); - if (lib2 is ResolvedLibraryResult) { - return lib2.element; - } - return null; - } - - final dependencies = <({LibraryElement lib, DirectiveTarget dir, bool onClient, bool onServer})>[]; - - for (final unit in result.units) { - for (final directive in unit.unit.directives) { - if (directive is NamespaceDirective) { - final configuration = directive.configurations; - - const clientLibs = ['js_interop', 'js_interop_unsafe', 'html', 'js', 'js_util']; - const serverLibs = ['io', 'ffi', 'isolate', 'mirrors']; - - final libConfigurations = configuration.where( - (c) => - c.name.components.length == 3 && - c.name.components[0].name == 'dart' && - c.name.components[1].name == 'library', - ); - - final clientConfiguration = libConfigurations - .where((c) => clientLibs.contains(c.name.components.last.name)) - .firstOrNull; - final serverConfiguration = libConfigurations - .where((c) => serverLibs.contains(c.name.components.last.name)) - .firstOrNull; - - final baseLib = getBaseLibraryForDirective(directive); - if (baseLib == null) { - data.logger.write( - 'Tooling Daemon: Could not resolve base library for ${directive.uri.stringValue}', - level: Level.warning, - ); - } - - final baseLoc = unit.lineInfo.getLocation(directive.offset); - final baseDir = DirectiveTarget( - directive.uri.stringValue ?? '', - baseLib?.uri.toString() ?? '', - baseLoc.lineNumber, - baseLoc.columnNumber, - directive.length, - ); - - if (clientConfiguration == null && serverConfiguration == null) { - // This is a general import (or with unsupported configurations), add to general dependencies - if (baseLib != null) { - dependencies.add((lib: baseLib, dir: baseDir, onClient: true, onServer: true)); - } - continue; - } - - if (clientConfiguration != null) { - final clientLib = await resolveLibraryFromUri(clientConfiguration.uri.stringValue); - if (clientLib != null) { - final clientLoc = unit.lineInfo.getLocation(clientConfiguration.uri.offset); - final clientDir = DirectiveTarget( - clientConfiguration.uri.stringValue ?? '', - clientLib.uri.toString(), - clientLoc.lineNumber, - clientLoc.columnNumber, - clientConfiguration.uri.length, - ); - dependencies.add((lib: clientLib, dir: clientDir, onClient: true, onServer: false)); - } else { - data.logger.write( - 'Tooling Daemon: Could not resolve client library for ${directive.uri.stringValue}', - level: Level.warning, - ); - } - } else { - // On the client, the base import is used. - if (baseLib != null) { - dependencies.add((lib: baseLib, dir: baseDir, onClient: true, onServer: false)); - } - } - - if (serverConfiguration != null) { - final serverLib = await resolveLibraryFromUri(serverConfiguration.uri.stringValue); - if (serverLib != null) { - final serverLoc = unit.lineInfo.getLocation(serverConfiguration.uri.offset); - final serverDir = DirectiveTarget( - serverConfiguration.uri.stringValue ?? '', - serverLib.uri.toString(), - serverLoc.lineNumber, - serverLoc.columnNumber, - serverConfiguration.uri.length, - ); - dependencies.add((lib: serverLib, dir: serverDir, onClient: false, onServer: true)); - } else { - data.logger.write( - 'Tooling Daemon: Could not resolve server library for ${directive.uri.stringValue}', - level: Level.warning, - ); - } - } else { - // On the server, the base import is used. - if (baseLib != null) { - dependencies.add((lib: baseLib, dir: baseDir, onClient: false, onServer: true)); - } - } - } - } - } - - return dependencies; - } -} - -class InspectItemDependency { - InspectItemDependency(this.item, this.dir, this.onClient, this.onServer); - - final InspectDataItem item; - final DirectiveTarget dir; - final bool onClient; - final bool onServer; - - DirectiveTarget? invalidOnClient; - DirectiveTarget? invalidOnServer; -} - -class DirectiveTarget { - final String uri; - final String target; - final int line; - final int character; - final int length; - - DirectiveTarget(this.uri, this.target, this.line, this.character, this.length); - - Map toJson() { - return {'uri': uri, 'target': target, 'line': line, 'character': character, 'length': length}; - } - - DirectiveTarget withTarget(DirectiveTarget childDir) { - return DirectiveTarget(uri, childDir.target, line, character, length); - } -} - -class InspectTarget { - final String path; - final String name; - final int line; - final int character; - - InspectTarget(this.path, this.name, this.line, this.character); - - Map toJson() { - return {'path': path, 'name': name, 'line': line, 'character': character}; - } -} diff --git a/packages/jaspr_cli/test/domains/html_test.dart b/packages/jaspr_cli/test/domains/html_test.dart deleted file mode 100644 index fb93a2b74..000000000 --- a/packages/jaspr_cli/test/domains/html_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:jaspr_cli/src/domains/html_domain.dart'; -import 'package:test/test.dart'; - -void main() { - Future convert(String html, {String? query}) => HtmlDomain.convertHtml(html, query); - - group('html domain', () { - test('converts simple element with text', () async { - final result = await convert('
Hello
'); - expect( - result, - equals( - 'div([\n' - ' .text(\'Hello\'),\n' - '])', - ), - ); - }); - - test('converts nested elements', () async { - final result = await convert('
  • One
  • Two
'); - expect( - result, - equals( - 'ul([\n' - ' li([\n' - ' .text(\'One\'),\n' - ' ]),\n' - ' li([\n' - ' .text(\'Two\'),\n' - ' ]),\n' - '])', - ), - ); - }); - - test('converts element with attributes, id and class', () async { - final result = await convert('

'); - expect(result, equals('p(id: \'foo\', classes: \'bar\', attributes: {\'attr\': \'value\'}, [])')); - }); - - test('converts element with multiline text', () async { - final result = await convert('
Hello\nWorld
'); - expect( - result, - equals( - 'div([\n' - ' .text(\'\'\'Hello\n' - 'World\'\'\'),\n' - '])', - ), - ); - }); - - test('converts element with special attribute (a href)', () async { - final result = await convert('Link'); - expect( - result, - equals( - 'a(href: \'/foo\', [\n' - ' .text(\'Link\'),\n' - '])', - ), - ); - }); - - test('escapes quotes in text and attributes', () async { - final result = await convert('
She said \'Hi\'
'); - expect( - result, - equals( - 'div(attributes: {\'title\': \'He said \\\'Hello\\\'\'}, [\n' - ' .text(\'She said \\\'Hi\\\'\'),\n' - '])', - ), - ); - }); - - test('converts content of script and style tags', () async { - final result = await convert( - '
HelloWorld
', - ); - expect( - result, - equals( - 'div([\n' - ' .text(\'Hello\'),\n' - ' Component.element(tag: \'style\', children: [\n' - ' .text(\'.foo { color: red; }\')\n' - ' ]),\n' - ' script(content: \'alert("Hi")\'),\n' - ' .text(\'World\'),\n' - '])', - ), - ); - }); - - test('converts full document', () async { - final result = await convert('Hello'); - expect(result, equals('html([\n head([]),\n body([\n .text(\'Hello\'),\n ]),\n])')); - }); - - test('converts body', () async { - final result = await convert('Hello'); - expect(result, equals('body([\n .text(\'Hello\'),\n])')); - }); - - test('applies query to converted html', () async { - final result = await convert('
Hello

World

', query: 'p'); - expect(result, equals('p([\n .text(\'World\'),\n])')); - }); - }); -} diff --git a/packages/jaspr_cli/test/domains/scopes_test.dart b/packages/jaspr_cli/test/domains/scopes_test.dart deleted file mode 100644 index d6aaa891d..000000000 --- a/packages/jaspr_cli/test/domains/scopes_test.dart +++ /dev/null @@ -1,349 +0,0 @@ -import 'dart:io'; - -import 'package:jaspr_cli/src/daemon/daemon.dart'; -import 'package:jaspr_cli/src/domains/scopes_domain.dart'; -import 'package:jaspr_cli/src/logging.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; - -class MockDaemon extends Mock implements Daemon { - MockDaemon(); -} - -class FakeLogger extends Fake implements Logger { - FakeLogger(); - - @override - void write(String message, {Tag? tag, Level level = Level.info, ProgressState? progress}) { - print('[$level]${tag != null ? '[$tag]' : ''} $message'); - } -} - -void main() { - late MockDaemon daemon; - late ScopesDomain domain; - - late String projectPath; - - setUp(() async { - domain = ScopesDomain(daemon = MockDaemon(), FakeLogger()); - }); - - tearDown(() async { - domain.dispose(); - try { - await Directory(projectPath).delete(recursive: true); - } catch (_) {} - }); - - Future expectScopesResult(Object? data) async { - await untilCalled( - () => daemon.send( - any( - that: predicate>( - (map) => map['event'] == 'scopes.status' && (map['params'] as Map)[projectPath] == false, - ), - ), - ), - ); - - final messages = verify(() => daemon.send(captureAny())).captured; - expect(messages.length, 3); - expect( - messages[0], - equals({ - 'event': 'scopes.status', - 'params': {projectPath: true}, - }), - ); - - expect( - messages[1], - equals({ - 'event': 'scopes.result', - 'params': data, - }), - ); - - expect( - messages[2], - equals({ - 'event': 'scopes.status', - 'params': {projectPath: false}, - }), - ); - } - - group('scopes domain', () { - test('analyzes simple scopes', () async { - projectPath = setUpProject({ - 'main.server.dart': ''' -import 'package:jaspr/jaspr.dart'; -import 'app.dart'; - -void main() { - runApp(App()); -} -''', - 'app.dart': ''' -import 'package:jaspr/jaspr.dart'; -import 'page.dart'; - -@client -class App extends StatelessComponent { - @override - Component build(BuildContext context) { - return Page(); - } -} -''', - 'page.dart': ''' -import 'package:jaspr/jaspr.dart'; - -class Page extends StatelessComponent { - @override - Component build(BuildContext context) { - return text('Hello, World!'); - } -} -''', - }); - await domain.registerScopes({ - 'folders': [projectPath], - }); - - await expectScopesResult({ - '$projectPath/lib/app.dart': { - 'components': ['App'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': [ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ], - }, - '$projectPath/lib/page.dart': { - 'components': ['Page'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': [ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ], - }, - }); - }); - - test('analyzes multiple server entrypoints', () async { - projectPath = setUpProject({ - 'main.server.dart': ''' -import 'package:jaspr/jaspr.dart'; -import 'app.dart'; - -void main() { - runApp(App()); -} -''', - 'other.server.dart': ''' -import 'package:jaspr/jaspr.dart'; -import 'page.dart'; - -void main() { - runApp(Page()); -} -''', - 'app.dart': ''' -import 'package:jaspr/jaspr.dart'; -import 'page.dart'; - -@client -class App extends StatelessComponent { - @override - Component build(BuildContext context) { - return Page(); - } -} -''', - 'page.dart': ''' -import 'package:jaspr/jaspr.dart'; - -class Page extends StatelessComponent { - @override - Component build(BuildContext context) { - return text('Hello, World!'); - } -} -''', - }); - await domain.registerScopes({ - 'folders': [projectPath], - }); - - await expectScopesResult({ - '$projectPath/lib/app.dart': { - 'components': ['App'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': [ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ], - }, - '$projectPath/lib/page.dart': { - 'components': ['Page'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': unorderedEquals([ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - { - 'path': '$projectPath/lib/other.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ]), - }, - }); - }); - - test('analyzes unallowed imports', () async { - projectPath = setUpProject({ - 'main.server.dart': ''' -import 'package:jaspr/jaspr.dart'; -import 'app.dart'; - -void main() { - runApp(App()); -} -''', - 'app.dart': ''' -import 'package:jaspr/server.dart'; -import 'package:web/web.dart'; - -@client -class App extends StatelessComponent { - @override - Component build(BuildContext context) { - return text('Hello, World!'); - } -} -''', - }); - await domain.registerScopes({ - 'folders': [projectPath], - }); - - await expectScopesResult({ - '$projectPath/lib/app.dart': { - 'components': ['App'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': [ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ], - 'invalidDependencies': [ - { - 'uri': 'package:jaspr/server.dart', - 'invalidOnClient': { - 'uri': 'package:jaspr/server.dart', - 'target': 'package:jaspr/server.dart', - 'line': 1, - 'character': 1, - 'length': 35, - }, - }, - { - 'uri': 'package:web/web.dart', - 'invalidOnServer': { - 'uri': 'package:web/web.dart', - 'target': 'package:web/web.dart', - 'line': 2, - 'character': 1, - 'length': 30, - }, - }, - ], - }, - }); - }); - }); -} - -String setUpProject(Map files) { - final tempDir = Directory.systemTemp.createTempSync('jaspr_test_project_'); - final projectPath = tempDir.path; - - Directory('$projectPath/lib').createSync(recursive: true); - - File('$projectPath/pubspec.yaml').writeAsStringSync(''' -name: project -environment: - sdk: '>=3.0.0 <4.0.0' -dependencies: - jaspr: any -jaspr: - mode: server -'''); - - for (final entry in files.entries) { - final file = File('$projectPath/lib/${entry.key}'); - file.createSync(recursive: true); - file.writeAsStringSync(entry.value); - } - - Process.runSync('dart', ['pub', 'get'], workingDirectory: projectPath); - - return projectPath; -} diff --git a/packages/jaspr_cli/test/src/commands/convert_html_command_test.dart b/packages/jaspr_cli/test/src/commands/convert_html_command_test.dart index 882cae6ba..0716fc75f 100644 --- a/packages/jaspr_cli/test/src/commands/convert_html_command_test.dart +++ b/packages/jaspr_cli/test/src/commands/convert_html_command_test.dart @@ -1,4 +1,5 @@ import 'package:jaspr_cli/src/command_runner.dart'; +import 'package:jaspr_cli/src/commands/convert_html_command.dart'; import 'package:test/test.dart'; import '../fakes/fake_io.dart'; @@ -14,14 +15,14 @@ void main() { runner = JasprCommandRunner(false); }); - test('fails when neither --file nor --url is provided', () async { + test('fails when neither --html, --file nor --url is provided', () async { await io.runZoned(() async { io.stubDartSDK(); final result = await runner.run(['convert-html']); expect(result, equals(1)); - await expectLater(io.stderr.queue, emits(contains('Either --file or --url must be provided.'))); + await expectLater(io.stderr.queue, emits(contains('Either --html, --file or --url must be provided.'))); }); }); @@ -36,6 +37,20 @@ void main() { }); }); + test('converts html from string', () async { + await io.runZoned(() async { + io.stubDartSDK(); + + final result = await runner.run(['convert-html', '--html', '"

Hello

"', '--json']); + + expect(result, equals(0)); + await expectLater( + io.stdout.queue, + emits('{"result":"div([\\n p([\\n .text(\'Hello\'),\\n ]),\\n])"}'), + ); + }); + }); + test('converts html from a file', () async { await io.runZoned(() async { io.stubDartSDK(); @@ -44,18 +59,12 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('

Hello

'); - final result = await runner.run(['convert-html', '--file', '/root/test.html']); + final result = await runner.run(['convert-html', '--file', '/root/test.html', '--json']); expect(result, equals(0)); await expectLater( io.stdout.queue, - emitsInOrder([ - 'div([', - 'p([', - ".text('Hello'),", - ']),', - '])', - ]), + emits('{"result":"div([\\n p([\\n .text(\'Hello\'),\\n ]),\\n])"}'), ); }); }); @@ -68,20 +77,125 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('
Header

Content

'); - final result = await runner.run(['convert-html', '--file', '/root/test.html', '--query', 'main']); + final result = await runner.run(['convert-html', '--file', '/root/test.html', '--query', 'main', '--json']); expect(result, equals(0)); await expectLater( io.stdout.queue, - emitsInOrder([ - 'main_([', - 'p([', - ".text('Content'),", - ']),', + emits('{"result":"main_([\\n p([\\n .text(\'Content\'),\\n ]),\\n])"}'), + ); + }); + }); + + group('conversion', () { + String convert(String html, {String? query}) { + return ConvertHtmlCommand().convertHtml(html, query); + } + + test('converts simple element with text', () { + final result = convert('
Hello
'); + expect( + result, + equals( + 'div([\n' + ' .text(\'Hello\'),\n' '])', - ]), + ), ); }); + + test('converts nested elements', () { + final result = convert('
  • One
  • Two
'); + expect( + result, + equals( + 'ul([\n' + ' li([\n' + ' .text(\'One\'),\n' + ' ]),\n' + ' li([\n' + ' .text(\'Two\'),\n' + ' ]),\n' + '])', + ), + ); + }); + + test('converts element with attributes, id and class', () { + final result = convert('

'); + expect(result, equals('p(id: \'foo\', classes: \'bar\', attributes: {\'attr\': \'value\'}, [])')); + }); + + test('converts element with multiline text', () { + final result = convert('
Hello\nWorld
'); + expect( + result, + equals( + 'div([\n' + ' .text(\'\'\'Hello\n' + 'World\'\'\'),\n' + '])', + ), + ); + }); + + test('converts element with special attribute (a href)', () { + final result = convert('Link'); + expect( + result, + equals( + 'a(href: \'/foo\', [\n' + ' .text(\'Link\'),\n' + '])', + ), + ); + }); + + test('escapes quotes in text and attributes', () { + final result = convert('
She said \'Hi\'
'); + expect( + result, + equals( + 'div(attributes: {\'title\': \'He said \\\'Hello\\\'\'}, [\n' + ' .text(\'She said \\\'Hi\\\'\'),\n' + '])', + ), + ); + }); + + test('converts content of script and style tags', () { + final result = convert( + '
HelloWorld
', + ); + expect( + result, + equals( + 'div([\n' + ' .text(\'Hello\'),\n' + ' Component.element(tag: \'style\', children: [\n' + ' .text(\'.foo { color: red; }\')\n' + ' ]),\n' + ' script(content: \'alert("Hi")\'),\n' + ' .text(\'World\'),\n' + '])', + ), + ); + }); + + test('converts full document', () { + final result = convert('Hello'); + expect(result, equals('html([\n head([]),\n body([\n .text(\'Hello\'),\n ]),\n])')); + }); + + test('converts body', () { + final result = convert('Hello'); + expect(result, equals('body([\n .text(\'Hello\'),\n])')); + }); + + test('applies query to converted html', () { + final result = convert('
Hello

World

', query: 'p'); + expect(result, equals('p([\n .text(\'World\'),\n])')); + }); }); }); } diff --git a/packages/jaspr_lints/lib/src/rules/unsafe_imports_rule.dart b/packages/jaspr_lints/lib/src/rules/unsafe_imports_rule.dart index 962d81b74..f523872fc 100644 --- a/packages/jaspr_lints/lib/src/rules/unsafe_imports_rule.dart +++ b/packages/jaspr_lints/lib/src/rules/unsafe_imports_rule.dart @@ -5,6 +5,7 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/diagnostic/diagnostic.dart'; +import 'package:analyzer/file_system/file_system.dart'; import 'package:yaml/yaml.dart'; import '../utils.dart'; @@ -17,12 +18,14 @@ class UnsafeImportsRule extends AnalysisRule { severity: DiagnosticSeverity.ERROR, ); - UnsafeImportsRule() + UnsafeImportsRule({this.resourceProvider}) : super( name: 'unsafe_imports', description: 'Detects unsafe platform imports.', ); + final ResourceProvider? resourceProvider; + final ScopeTree scopeTree = ScopeTree(); @override @@ -44,7 +47,7 @@ class UnsafeImportsRule extends AnalysisRule { } if (context.package?.root case final root?) { - scopeTree.writeScopes(root.path); + scopeTree.writeScopes(root.path, resourceProvider: resourceProvider); } } diff --git a/packages/jaspr_lints/lib/src/utils/scope_tree.dart b/packages/jaspr_lints/lib/src/utils/scope_tree.dart index cc89c383d..a592d0aa2 100644 --- a/packages/jaspr_lints/lib/src/utils/scope_tree.dart +++ b/packages/jaspr_lints/lib/src/utils/scope_tree.dart @@ -1,9 +1,10 @@ import 'dart:convert'; -import 'dart:io'; +import 'dart:io' as io; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/file_system/file_system.dart'; import 'package:path/path.dart' as path; import 'logging.dart'; @@ -53,13 +54,22 @@ class ScopeTree { return node; } - void writeScopes(String rootPath) { - final file = File(path.join(rootPath, '.dart_tool', 'jaspr', 'scopes.json')); - if (!dirty && file.existsSync()) { - return; + void writeScopes(String rootPath, {ResourceProvider? resourceProvider}) { + late final scopesContent = jsonEncode(buildScopes()); + + final scopesPath = path.join(rootPath, '.dart_tool', 'jaspr', 'scopes.json'); + if (resourceProvider != null) { + final file = resourceProvider.getFile(scopesPath); + if (!dirty && file.exists) return; + + file.writeAsStringSync(scopesContent); + } else { + final file = io.File(scopesPath); + if (!dirty && file.existsSync()) return; + + file.createSync(recursive: true); + file.writeAsStringSync(scopesContent); } - file.createSync(recursive: true); - file.writeAsStringSync(jsonEncode(buildScopes())); dirty = false; } diff --git a/packages/jaspr_lints/pubspec.yaml b/packages/jaspr_lints/pubspec.yaml index 389869276..054a4884b 100644 --- a/packages/jaspr_lints/pubspec.yaml +++ b/packages/jaspr_lints/pubspec.yaml @@ -24,3 +24,7 @@ dependencies: analyzer_plugin: any path: ^1.9.0 yaml: ^3.1.2 + +dev_dependencies: + analyzer_testing: ^0.1.9 + test_reflective_loader: ^0.4.0 diff --git a/packages/jaspr_lints/test/jaspr_package.dart b/packages/jaspr_lints/test/jaspr_package.dart new file mode 100644 index 000000000..4ab564f45 --- /dev/null +++ b/packages/jaspr_lints/test/jaspr_package.dart @@ -0,0 +1,69 @@ +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; + +extension JasprAnalysisRuleTest on AnalysisRuleTest { + void setUpJasprPackage() { + newPackage('jaspr') + ..addFile('lib/jaspr.dart', r''' + export 'src/framework/framework.dart'; + export 'src/foundation/annotations.dart'; + ''') + ..addFile('lib/dom.dart', r''' + export 'src/dom/styles/css.dart'; + export 'src/dom/styles/styles.dart'; + ''') + ..addFile('lib/src/framework/framework.dart', r''' + class Component { + const Component(); + factory Component.element({required String tag}) => Component._(); + } + + class div extends Component { + div(List children, {String? id, String? classes}) : super(); + } + class p extends Component { + p(List children, {String? id, String? classes}) : super(); + } + ''') + ..addFile('lib/src/dom/styles/css.dart', r''' + import 'styles.dart'; + + const css = _CssAnnotation(); + class _CssAnnotation with StylesMixin { + const _CssAnnotation(); + } + + class StyleRule { + const StyleRule(); + } + ''') + ..addFile('lib/src/foundation/annotations.dart', r''' + const client = _Client(); + class _Client { const _Client(); } + ''') + ..addFile('lib/src/dom/styles/styles.dart', r''' + class Styles { + const Styles({ + String? display, + String? width, + String? height, + String? padding, + String? margin, + String? color, + String? fontSize, + }); + } + + mixin class StylesMixin { + void styles({ + String? display, + String? width, + String? height, + String? padding, + String? margin, + String? color, + String? fontSize, + }) {} + } + '''); + } +} diff --git a/packages/jaspr_lints/test/rules/prefer_html_components_rule_test.dart b/packages/jaspr_lints/test/rules/prefer_html_components_rule_test.dart new file mode 100644 index 000000000..1c65ef5fb --- /dev/null +++ b/packages/jaspr_lints/test/rules/prefer_html_components_rule_test.dart @@ -0,0 +1,48 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:jaspr_lints/src/rules/prefer_html_components_rule.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../jaspr_package.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(PreferHtmlComponentsRuleTest); + }); +} + +@reflectiveTest +class PreferHtmlComponentsRuleTest extends AnalysisRuleTest { + @override + void setUp() { + rule = PreferHtmlComponentsRule(); + setUpJasprPackage(); + super.setUp(); + } + + void test_uses_known_tag() async { + await assertDiagnostics( + "import 'package:jaspr/jaspr.dart';\n\n" + 'Component run() {\n' + " return Component.element(tag: 'div');\n" + '}', + [ + lint( + 63, + 17, + messageContainsAll: ["Prefer using 'div(...)' over 'Component.element(tag: \"div\", ...)'"], + ), + ], + ); + } + + void test_uses_unknown_tag() async { + await assertNoDiagnostics( + "import 'package:jaspr/jaspr.dart';\n\n" + 'Component run() {\n' + " return Component.element(tag: 'unknown');\n" + '}', + ); + } +} diff --git a/packages/jaspr_lints/test/rules/prefer_styles_getter_rule_test.dart b/packages/jaspr_lints/test/rules/prefer_styles_getter_rule_test.dart new file mode 100644 index 000000000..28a994d5d --- /dev/null +++ b/packages/jaspr_lints/test/rules/prefer_styles_getter_rule_test.dart @@ -0,0 +1,73 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:jaspr_lints/src/rules/prefer_styles_getter_rule.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../jaspr_package.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(PreferStylesGetterRuleTest); + }); +} + +@reflectiveTest +class PreferStylesGetterRuleTest extends AnalysisRuleTest { + @override + void setUp() { + rule = PreferStylesGetterRule(); + setUpJasprPackage(); + super.setUp(); + } + + void test_uses_global_variable() async { + await assertDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + '@css\n' + 'final styles = [\n' + ' StyleRule(),\n' + '];', + [ + lint(39, 12), + ], + ); + } + + void test_uses_static_field() async { + await assertDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + 'class MyComponent {\n' + ' @css\n' + ' static final styles = [\n' + ' StyleRule(),\n' + ' ];\n' + '}', + [ + lint(63, 19), + ], + ); + } + + void test_uses_global_getter() async { + await assertNoDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + '@css\n' + 'List get styles => [\n' + ' StyleRule(),\n' + '];', + ); + } + + void test_uses_static_getter() async { + await assertNoDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + 'class MyComponent {\n' + ' @css\n' + ' static List get styles => [\n' + ' StyleRule(),\n' + ' ];\n' + '}', + ); + } +} diff --git a/packages/jaspr_lints/test/rules/sort_children_last_rule_test.dart b/packages/jaspr_lints/test/rules/sort_children_last_rule_test.dart new file mode 100644 index 000000000..1e60634ed --- /dev/null +++ b/packages/jaspr_lints/test/rules/sort_children_last_rule_test.dart @@ -0,0 +1,45 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:jaspr_lints/src/rules/sort_children_last_rule.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../jaspr_package.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(SortChildrenLastRuleTest); + }); +} + +@reflectiveTest +class SortChildrenLastRuleTest extends AnalysisRuleTest { + @override + void setUp() { + rule = SortChildrenLastRule(); + setUpJasprPackage(); + super.setUp(); + } + + void test_uses_children_first() async { + await assertDiagnostics( + "import 'package:jaspr/jaspr.dart';\n\n" + 'Component run() {\n' + ' return div([p([], id: "test")], classes: "main");\n' + '}', + [ + lint(63, 41), + lint(68, 17), + ], + ); + } + + void test_uses_children_last() async { + await assertNoDiagnostics( + "import 'package:jaspr/jaspr.dart';\n\n" + 'Component run() {\n' + ' return div(classes: "main", [p(id: "test", [])]);\n' + '}', + ); + } +} diff --git a/packages/jaspr_lints/test/rules/styles_ordering_rule_test.dart b/packages/jaspr_lints/test/rules/styles_ordering_rule_test.dart new file mode 100644 index 000000000..33072a6c7 --- /dev/null +++ b/packages/jaspr_lints/test/rules/styles_ordering_rule_test.dart @@ -0,0 +1,78 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:jaspr_lints/src/rules/styles_ordering_rule.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../jaspr_package.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(StylesOrderingRuleTest); + }); +} + +@reflectiveTest +class StylesOrderingRuleTest extends AnalysisRuleTest { + @override + void setUp() { + rule = StylesOrderingRule(); + setUpJasprPackage(); + super.setUp(); + } + + // --- Styles() constructor tests --- + + void test_constructor_correct_order() async { + await assertNoDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + 'Styles run() {\n' + ' return Styles(display: "block", width: "100px", color: "red");\n' + '}', + ); + } + + void test_constructor_wrong_order() async { + await assertDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + 'Styles run() {\n' + ' return Styles(color: "red", display: "block", width: "100px");\n' + '}', + [ + lint(79, 16), + ], + ); + } + + void test_constructor_single_param() async { + await assertNoDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + 'Styles run() {\n' + ' return Styles(color: "red");\n' + '}', + ); + } + + // --- styles() method tests --- + + void test_method_correct_order() async { + await assertNoDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + 'void run() {\n' + ' css.styles(display: "block", width: "100px", color: "red");\n' + '}', + ); + } + + void test_method_wrong_order() async { + await assertDiagnostics( + "import 'package:jaspr/dom.dart';\n\n" + 'void run() {\n' + ' css.styles(color: "red", display: "block", width: "100px");\n' + '}', + [ + lint(74, 16), + ], + ); + } +} diff --git a/packages/jaspr_lints/test/rules/unsafe_imports_rule_test.dart b/packages/jaspr_lints/test/rules/unsafe_imports_rule_test.dart new file mode 100644 index 000000000..1351a0935 --- /dev/null +++ b/packages/jaspr_lints/test/rules/unsafe_imports_rule_test.dart @@ -0,0 +1,447 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:jaspr_lints/src/rules/unsafe_imports_rule.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../jaspr_package.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(UnsafeImportsServerTest); + defineReflectiveTests(UnsafeImportsClientTest); + defineReflectiveTests(UnsafeImportsComponentTest); + }); +} + +abstract class UnsafeImportsBaseTest extends AnalysisRuleTest { + @override + void setUp() { + rule = UnsafeImportsRule(resourceProvider: resourceProvider); + setUpJasprPackage(); + super.setUp(); + } +} + +@reflectiveTest +class UnsafeImportsServerTest extends UnsafeImportsBaseTest { + @override + String get testFileName => 'main.server.dart'; + + void test_import_js_interop_fails() async { + await assertDiagnostics( + '// ignore_for_file: unused_import\n' + "import 'dart:js_interop';", + [ + lint( + 34, + 25, + messageContainsAll: [ + "Unsafe import: 'dart:js_interop' is not available on the server.\nTry using 'package:universal_web/js_interop.dart' instead.", + ], + ), + ], + ); + } + + void test_import_js_interop_transitive_fails() async { + final aFile = newFile('$testPackageLibPath/a.dart', "import 'dart:js_interop';"); + final bFile = newFile('$testPackageLibPath/b.dart', "import 'a.dart';\nimport 'package:jaspr/client.dart';"); + + final message = + "Unsafe import: 'b.dart' imports other unsafe libraries which are not available on the server. See below for details."; + final contextMessageText = + "Unsafe import 'dart:js_interop'. Try using 'package:universal_web/js_interop.dart' instead."; + final contextMessageText2 = + "Unsafe import 'package:jaspr/client.dart'. Try using 'package:jaspr/jaspr.dart' instead."; + + await assertDiagnostics( + '// ignore_for_file: unused_import\n' + "import 'b.dart';", + [ + lint( + 34, + 16, + messageContainsAll: [message], + contextMessages: [ + contextMessage(aFile, 7, 17, textContains: [contextMessageText]), + contextMessage(bFile, 24, 27, textContains: [contextMessageText2]), + ], + ), + ], + ); + } + + void test_import_io_ok() async { + await assertNoDiagnostics( + '// ignore_for_file: unused_import\n' + "import 'dart:io';", + ); + } +} + +@reflectiveTest +class UnsafeImportsClientTest extends UnsafeImportsBaseTest { + @override + String get testFileName => 'main.client.dart'; + + void test_import_io_fails() async { + await assertDiagnostics( + '// ignore_for_file: unused_import\n' + "import 'dart:io';", + [ + lint( + 34, + 17, + messageContainsAll: [ + "Unsafe import: 'dart:io' is not available on the client.\nTry moving this out of the client scope or use a conditional import.", + ], + ), + ], + ); + } + + void test_import_io_transitive_fails() async { + final aFile = newFile('$testPackageLibPath/a.dart', "import 'dart:io';"); + final bFile = newFile('$testPackageLibPath/b.dart', "import 'a.dart';\nimport 'package:jaspr/server.dart';"); + + final message = + "Unsafe import: 'b.dart' imports other unsafe libraries which are not available on the client. See below for details."; + final contextMessageText = + "Unsafe import 'dart:io'. Try moving this out of the client scope or use a conditional import."; + final contextMessageText2 = + "Unsafe import 'package:jaspr/server.dart'. Try using 'package:jaspr/jaspr.dart' instead."; + + await assertDiagnostics( + '// ignore_for_file: unused_import\n' + "import 'b.dart';", + [ + lint( + 34, + 16, + messageContainsAll: [message], + contextMessages: [ + contextMessage(aFile, 7, 9, textContains: [contextMessageText]), + contextMessage(bFile, 24, 27, textContains: [contextMessageText2]), + ], + ), + ], + ); + } + + void test_import_js_interop_ok() async { + await assertNoDiagnostics( + '// ignore_for_file: unused_import\n' + "import 'dart:js_interop';", + ); + } +} + +@reflectiveTest +class UnsafeImportsComponentTest extends UnsafeImportsBaseTest { + @override + String get testFileName => 'component.dart'; + + void test_client_component_import_io_fails() async { + await assertDiagnostics( + '// ignore_for_file: unused_import\n' + "import 'package:jaspr/jaspr.dart';\n" + "import 'dart:io';\n\n" + '@client\n' + 'class MyComponent extends Component {}', + [ + lint( + 69, + 17, + messageContainsAll: [ + "Unsafe import: 'dart:io' is not available on the client.\nTry moving this out of the client scope or use a conditional import.", + ], + ), + ], + ); + } + + void test_client_component_import_io_transitive_fails() async { + final aFile = newFile('$testPackageLibPath/a.dart', "import 'dart:io';"); + final bFile = newFile('$testPackageLibPath/b.dart', "import 'a.dart';\nimport 'package:jaspr/server.dart';"); + + final message = + "Unsafe import: 'b.dart' imports other unsafe libraries which are not available on the client. See below for details."; + final contextMessageText = + "Unsafe import 'dart:io'. Try moving this out of the client scope or use a conditional import."; + final contextMessageText2 = + "Unsafe import 'package:jaspr/server.dart'. Try using 'package:jaspr/jaspr.dart' instead."; + + await assertDiagnostics( + '// ignore_for_file: unused_import\n' + "import 'package:jaspr/jaspr.dart';\n" + "import 'b.dart';\n\n" + '@client\n' + 'class MyComponent extends Component {}', + [ + lint( + 69, + 16, + messageContainsAll: [message], + contextMessages: [ + contextMessage(aFile, 7, 9, textContains: [contextMessageText]), + contextMessage(bFile, 24, 27, textContains: [contextMessageText2]), + ], + ), + ], + ); + } +} + +/* +class ScopesTest extends UnsafeImportsBaseTest { + @override + String get testFileName => 'main.server.dart'; + + void test_scopes_file_is_created() {} +} + +test('analyzes simple scopes', () async { + projectPath = setUpProject({ + 'main.server.dart': ''' +import 'package:jaspr/jaspr.dart'; +import 'app.dart'; + +void main() { + runApp(App()); +} +''', + 'app.dart': ''' +import 'package:jaspr/jaspr.dart'; +import 'page.dart'; + +@client +class App extends StatelessComponent { + @override + Component build(BuildContext context) { + return Page(); + } +} +''', + 'page.dart': ''' +import 'package:jaspr/jaspr.dart'; + +class Page extends StatelessComponent { + @override + Component build(BuildContext context) { + return text('Hello, World!'); + } +} +''', + }); + await domain.registerScopes({ + 'folders': [projectPath], + }); + + await expectScopesResult({ + '$projectPath/lib/app.dart': { + 'components': ['App'], + 'clientScopeRoots': [ + { + 'path': '$projectPath/lib/app.dart', + 'name': 'App', + 'line': 5, + 'character': 7, + }, + ], + 'serverScopeRoots': [ + { + 'path': '$projectPath/lib/main.server.dart', + 'name': 'main', + 'line': 4, + 'character': 6, + }, + ], + }, + '$projectPath/lib/page.dart': { + 'components': ['Page'], + 'clientScopeRoots': [ + { + 'path': '$projectPath/lib/app.dart', + 'name': 'App', + 'line': 5, + 'character': 7, + }, + ], + 'serverScopeRoots': [ + { + 'path': '$projectPath/lib/main.server.dart', + 'name': 'main', + 'line': 4, + 'character': 6, + }, + ], + }, + }); + }); + + test('analyzes multiple server entrypoints', () async { + projectPath = setUpProject({ + 'main.server.dart': ''' +import 'package:jaspr/jaspr.dart'; +import 'app.dart'; + +void main() { + runApp(App()); +} +''', + 'other.server.dart': ''' +import 'package:jaspr/jaspr.dart'; +import 'page.dart'; + +void main() { + runApp(Page()); +} +''', + 'app.dart': ''' +import 'package:jaspr/jaspr.dart'; +import 'page.dart'; + +@client +class App extends StatelessComponent { + @override + Component build(BuildContext context) { + return Page(); + } +} +''', + 'page.dart': ''' +import 'package:jaspr/jaspr.dart'; + +class Page extends StatelessComponent { + @override + Component build(BuildContext context) { + return text('Hello, World!'); + } +} +''', + }); + await domain.registerScopes({ + 'folders': [projectPath], + }); + + await expectScopesResult({ + '$projectPath/lib/app.dart': { + 'components': ['App'], + 'clientScopeRoots': [ + { + 'path': '$projectPath/lib/app.dart', + 'name': 'App', + 'line': 5, + 'character': 7, + }, + ], + 'serverScopeRoots': [ + { + 'path': '$projectPath/lib/main.server.dart', + 'name': 'main', + 'line': 4, + 'character': 6, + }, + ], + }, + '$projectPath/lib/page.dart': { + 'components': ['Page'], + 'clientScopeRoots': [ + { + 'path': '$projectPath/lib/app.dart', + 'name': 'App', + 'line': 5, + 'character': 7, + }, + ], + 'serverScopeRoots': unorderedEquals([ + { + 'path': '$projectPath/lib/main.server.dart', + 'name': 'main', + 'line': 4, + 'character': 6, + }, + { + 'path': '$projectPath/lib/other.server.dart', + 'name': 'main', + 'line': 4, + 'character': 6, + }, + ]), + }, + }); + }); + + test('analyzes unallowed imports', () async { + projectPath = setUpProject({ + 'main.server.dart': ''' +import 'package:jaspr/jaspr.dart'; +import 'app.dart'; + +void main() { + runApp(App()); +} +''', + 'app.dart': ''' +import 'package:jaspr/server.dart'; +import 'package:web/web.dart'; + +@client +class App extends StatelessComponent { + @override + Component build(BuildContext context) { + return text('Hello, World!'); + } +} +''', + }); + await domain.registerScopes({ + 'folders': [projectPath], + }); + + await expectScopesResult({ + '$projectPath/lib/app.dart': { + 'components': ['App'], + 'clientScopeRoots': [ + { + 'path': '$projectPath/lib/app.dart', + 'name': 'App', + 'line': 5, + 'character': 7, + }, + ], + 'serverScopeRoots': [ + { + 'path': '$projectPath/lib/main.server.dart', + 'name': 'main', + 'line': 4, + 'character': 6, + }, + ], + 'invalidDependencies': [ + { + 'uri': 'package:jaspr/server.dart', + 'invalidOnClient': { + 'uri': 'package:jaspr/server.dart', + 'target': 'package:jaspr/server.dart', + 'line': 1, + 'character': 1, + 'length': 35, + }, + }, + { + 'uri': 'package:web/web.dart', + 'invalidOnServer': { + 'uri': 'package:web/web.dart', + 'target': 'package:web/web.dart', + 'line': 2, + 'character': 1, + 'length': 30, + }, + }, + ], + }, + }); + }); + */ diff --git a/pubspec.lock b/pubspec.lock index 6f2596374..ef5c4e910 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.1" + analyzer_testing: + dependency: transitive + description: + name: analyzer_testing + sha256: "0ec2c743b6dbe9fddb4853574b1d9ede046cab6013e84d044aa291d018518c9d" + url: "https://pub.dev" + source: hosted + version: "0.1.9" ansi_styles: dependency: transitive description: @@ -1828,6 +1836,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.15" + test_reflective_loader: + dependency: transitive + description: + name: test_reflective_loader + sha256: d828d5ca15179aaac2aaf8f510cf0a52ec28e0031681b044ec5e581a4b8002e7 + url: "https://pub.dev" + source: hosted + version: "0.4.0" timezone: dependency: transitive description: From 007a8d9c9693d52386cacd12c92fb95693a3e788 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Fri, 27 Mar 2026 16:49:56 +0100 Subject: [PATCH 3/6] add jaspr_lints to tested packages --- pubspec.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pubspec.yaml b/pubspec.yaml index d71da5a0b..777395c38 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ melos: jaspr_builder, jaspr_cli, jaspr_content, + jaspr_lints, jaspr_riverpod, jaspr_router, ] @@ -76,6 +77,7 @@ melos: jaspr_builder, jaspr_cli, jaspr_content, + jaspr_lints, jaspr_riverpod, jaspr_router, ] From 0d82f8305869a6261ecb678b45488e712db2712f Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Fri, 27 Mar 2026 21:37:39 +0100 Subject: [PATCH 4/6] update scopes handling in vscode extension --- .../src/code_lenses/component_code_lens.ts | 166 +++++---- modules/jaspr-code/src/extension.ts | 17 +- ...{html_domain.ts => html_paste_provider.ts} | 41 +-- modules/jaspr-code/src/jaspr/scopes_domain.ts | 192 ---------- .../jaspr-code/src/jaspr/tooling_daemon.ts | 252 ------------- .../lib/src/rules/styles_ordering_rule.dart | 6 - .../jaspr_lints/lib/src/utils/scope_tree.dart | 34 +- packages/jaspr_lints/pubspec.yaml | 1 + packages/jaspr_lints/test/jaspr_package.dart | 11 + .../test/rules/unsafe_imports_rule_test.dart | 338 +++++++++--------- 10 files changed, 309 insertions(+), 749 deletions(-) rename modules/jaspr-code/src/jaspr/{html_domain.ts => html_paste_provider.ts} (64%) delete mode 100644 modules/jaspr-code/src/jaspr/scopes_domain.ts delete mode 100644 modules/jaspr-code/src/jaspr/tooling_daemon.ts diff --git a/modules/jaspr-code/src/code_lenses/component_code_lens.ts b/modules/jaspr-code/src/code_lenses/component_code_lens.ts index cc2ccea02..e41623845 100644 --- a/modules/jaspr-code/src/code_lenses/component_code_lens.ts +++ b/modules/jaspr-code/src/code_lenses/component_code_lens.ts @@ -1,22 +1,46 @@ -import { lspToRange } from "../helpers/object_helper"; import * as vs from "vscode"; -import { ScopeResults, ScopesDomain, ScopeTarget } from "../jaspr/scopes_domain"; -import { dartExtensionApi } from "../api"; import { PublicOutline } from "dart-code/src/extension/api/interfaces"; +export type ScopeResults = { + locations: Record; + scopes: Record; +} + +export type ScopeLibraryResult = { + components: string[]; + clientScopeRoots?: string[]; + serverScopeRoots?: string[]; +}; + +export type ScopeLocation = { + path: string; + name: string; + line: number; + char: number; + length: number; +}; + + export class ComponentCodeLensProvider implements vs.CodeLensProvider, vs.Disposable { - private scopesDomain: ScopesDomain; + private watcher: vs.FileSystemWatcher; private _onDidChangeCodeLenses: vs.EventEmitter = new vs.EventEmitter(); public readonly onDidChangeCodeLenses: vs.Event = this._onDidChangeCodeLenses.event; private hintCommand: vs.Disposable; - private scopeResults: ScopeResults = {}; + private scopeResults: Record = {}; + + constructor() { + + this.watcher = vs.workspace.createFileSystemWatcher("**/.dart_tool/jaspr/scopes.json"); + + this.watcher.onDidChange(async (uri) => { + await this.loadScopes(uri); + this._onDidChangeCodeLenses.fire(); + }); - constructor(scopesDomain: ScopesDomain) { - this.scopesDomain = scopesDomain; - this.scopesDomain.onDidChangeScopes((results: ScopeResults) => { - this.scopeResults = results; + this.watcher.onDidCreate(async (uri) => { + await this.loadScopes(uri); this._onDidChangeCodeLenses.fire(); }); @@ -43,70 +67,68 @@ export class ComponentCodeLensProvider implements vs.CodeLensProvider, vs.Dispos ); } - public async provideCodeLenses( + private async loadScopes(uri: vs.Uri) { + const content = await vs.workspace.fs.readFile(uri); + this.scopeResults[uri.fsPath] = JSON.parse(content.toString()); + } + + public provideCodeLenses( document: vs.TextDocument, token: vs.CancellationToken - ): Promise { - const outline = await dartExtensionApi.workspace.getOutline( - document, - token - ); - if (!outline?.children?.length) { - return; - } - + ): vs.CodeLens[] | undefined { const results: vs.CodeLens[] = []; - const serverRoots: ScopeTarget[] = []; - const clientRoots: ScopeTarget[] = []; - const components: PublicOutline[] = []; + let scopeResults: ScopeResults | null = null; + let item: ScopeLibraryResult | null = null; + for (const key in this.scopeResults) { + scopeResults = this.scopeResults[key]; + item = scopeResults.scopes[document.uri.fsPath]; + + if (item) break; + } - const item = this.scopeResults[document.uri.fsPath]; - if (!item) { + if (!scopeResults || !item) { return; } - for (let child of outline.children) { - const component = item.components?.find((c) => c === child.element.name); - if (!component) { - continue; - } + const mapIdsToLocations = (ids?: string[]): vs.Location[] => { + if (!ids) return []; + return ids.map((id) => scopeResults.locations[id]).map(this.scopeLocationToLocation); + }; - serverRoots.push(...(item.serverScopeRoots ?? [])); - clientRoots.push(...(item.clientScopeRoots ?? [])); - components.push(child); - } + const serverRootLocations = mapIdsToLocations(item.serverScopeRoots); + const clientRootLocations = mapIdsToLocations(item.clientScopeRoots); + + const showScopeHint = (serverRootLocations.length > 0 || clientRootLocations.length > 0) && vs.workspace + .getConfiguration("jaspr.scopes", document) + .get("showHint", true); + + for (let componentId of item.components) { + + const location = scopeResults.locations[componentId]; + const range = this.scopeLocationToRange(location); - for (let component of components) { - if (serverRoots.length > 0) { + if (serverRootLocations.length > 0) { results.push( - this.createCodeLens( - document, - component, - "Server Scope", - serverRoots.map(targetToLocation) - ) + new vs.CodeLens(range, { + arguments: [document.uri, range.start, serverRootLocations, "peek"], + command: "editor.action.peekLocations", + title: "Server Scope", + }) ); } - if (clientRoots.length > 0) { + if (clientRootLocations.length > 0) { results.push( - this.createCodeLens( - document, - component, - "Client Scope", - clientRoots.map(targetToLocation) - ) + new vs.CodeLens(range, { + arguments: [document.uri, range.start, clientRootLocations, "peek"], + command: "editor.action.peekLocations", + title: "Client Scope", + }) ); } - if ( - (serverRoots.length > 0 || clientRoots.length > 0) && - vs.workspace - .getConfiguration("jaspr.scopes", document) - .get("showHint", true) - ) { - var range = lspToRange(component.codeRange); + if (showScopeHint) { results.push( new vs.CodeLens(range, { command: "jaspr.action.showScopeHint", @@ -119,29 +141,23 @@ export class ComponentCodeLensProvider implements vs.CodeLensProvider, vs.Dispos return results; } - private createCodeLens( - document: vs.TextDocument, - element: any, - name: string, - targets: vs.Location[] - ): vs.CodeLens { - var range = lspToRange(element.codeRange); - return new vs.CodeLens(range, { - arguments: [document.uri, range.start, targets, "peek"], - command: "editor.action.peekLocations", - title: name, - }); + private scopeLocationToLocation(location: ScopeLocation): vs.Location { + return new vs.Location( + vs.Uri.file(location.path), + new vs.Position(location.line - 1, location.char) + ); + } + + private scopeLocationToRange(location: ScopeLocation): vs.Range { + return new vs.Range( + new vs.Position(location.line - 1, location.char), + new vs.Position(location.line - 1, location.char + location.length) + ); } public dispose(): any { this._onDidChangeCodeLenses.dispose(); + this.watcher.dispose(); this.hintCommand.dispose(); } -} - -function targetToLocation(target: ScopeTarget): vs.Location { - return new vs.Location( - vs.Uri.file(target.path), - new vs.Position(target.line - 1, target.character) - ); -} +} \ No newline at end of file diff --git a/modules/jaspr-code/src/extension.ts b/modules/jaspr-code/src/extension.ts index aa7cb7947..e66804bc9 100644 --- a/modules/jaspr-code/src/extension.ts +++ b/modules/jaspr-code/src/extension.ts @@ -10,9 +10,7 @@ import { jasprClean, jasprDoctor, jasprServe } from "./commands"; import { findJasprProjectFolders, } from "./helpers/project_helper"; -import { JasprToolingDaemon } from "./jaspr/tooling_daemon"; -import { ScopesDomain } from "./jaspr/scopes_domain"; -import { HtmlDomain } from "./jaspr/html_domain"; +import { HtmlPasteProvider } from "./jaspr/html_paste_provider"; import { dartExtensionApi } from "./api"; import { ComponentCodeLensProvider } from "./code_lenses/component_code_lens"; import { ServeCodeLensProvider } from "./code_lenses/serve_code_lens"; @@ -67,17 +65,10 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("jaspr.serve", jasprServe(context)) ); - const toolingDaemon = new JasprToolingDaemon(); - context.subscriptions.push(toolingDaemon); - await toolingDaemon.start(context); - - const scopesDomain = new ScopesDomain(toolingDaemon); - context.subscriptions.push(scopesDomain); - context.subscriptions.push( vscode.languages.registerCodeLensProvider( { language: "dart", scheme: "file" }, - new ComponentCodeLensProvider(scopesDomain) + new ComponentCodeLensProvider() ) ); @@ -90,6 +81,6 @@ export async function activate(context: vscode.ExtensionContext) { ); } - const htmlDomain = new HtmlDomain(toolingDaemon); - context.subscriptions.push(htmlDomain); + const htmlPasteProvider = new HtmlPasteProvider(); + context.subscriptions.push(htmlPasteProvider); } diff --git a/modules/jaspr-code/src/jaspr/html_domain.ts b/modules/jaspr-code/src/jaspr/html_paste_provider.ts similarity index 64% rename from modules/jaspr-code/src/jaspr/html_domain.ts rename to modules/jaspr-code/src/jaspr/html_paste_provider.ts index ad3fc3276..872cb41f3 100644 --- a/modules/jaspr-code/src/jaspr/html_domain.ts +++ b/modules/jaspr-code/src/jaspr/html_paste_provider.ts @@ -1,10 +1,9 @@ import * as vscode from "vscode"; -import { JasprToolingDaemon } from "./tooling_daemon"; +import { runJaspr } from "../helpers/process_helper"; +import { cwd } from "process"; -export class HtmlDomain - implements vscode.DocumentPasteEditProvider, vscode.Disposable -{ - private toolingDaemon: JasprToolingDaemon; +export class HtmlPasteProvider + implements vscode.DocumentPasteEditProvider, vscode.Disposable { private pasteEditProviderDisposable: vscode.Disposable | undefined; static readonly kind = vscode.DocumentDropOrPasteEditKind.Empty.append( @@ -13,15 +12,13 @@ export class HtmlDomain "jaspr" ); - constructor(toolingDaemon: JasprToolingDaemon) { - this.toolingDaemon = toolingDaemon; - + constructor() { this.pasteEditProviderDisposable = vscode.languages.registerDocumentPasteEditProvider( { language: "dart" }, this, { - providedPasteEditKinds: [HtmlDomain.kind], + providedPasteEditKinds: [HtmlPasteProvider.kind], pasteMimeTypes: ["text/plain"], } ); @@ -34,30 +31,26 @@ export class HtmlDomain context: vscode.DocumentPasteEditContext, token: vscode.CancellationToken ) { - if ( - this.toolingDaemon.jasprVersion === undefined || - this.toolingDaemon.jasprVersion < "0.21.2" - ) { - return; - } + const content = await data.get("text/plain")?.asString(); - if (content?.startsWith("<")) { + if (content && /^<[a-z]+(\s+[^>]*)*>/i.test(content)) { try { - const result = await this.toolingDaemon.sendMessage( - "html.convert", - { html: content }, - 1000 - ); - + const output = await runJaspr(['convert-html', '--html', JSON.stringify(content), '--json'], cwd()); if (token.isCancellationRequested) { return; } + const result = output.split('\n').find((line) => line.startsWith('{"result":')); + if (!result) { + vscode.window.showErrorMessage("Failed to convert HTML to Jaspr: " + output); + return; + } + return [ new vscode.DocumentPasteEdit( - result, + JSON.parse(result)['result'], "Convert to Jaspr", - HtmlDomain.kind + HtmlPasteProvider.kind ), ]; } catch (e) { diff --git a/modules/jaspr-code/src/jaspr/scopes_domain.ts b/modules/jaspr-code/src/jaspr/scopes_domain.ts deleted file mode 100644 index f8011ba3c..000000000 --- a/modules/jaspr-code/src/jaspr/scopes_domain.ts +++ /dev/null @@ -1,192 +0,0 @@ -import * as vscode from "vscode"; -import { JasprToolingDaemon } from "./tooling_daemon"; -import { findJasprProjectFolders } from "../helpers/project_helper"; - -export type ScopeResults = Record; - -export type ScopeLibraryResult = { - components?: string[]; - clientScopeRoots?: ScopeTarget[]; - serverScopeRoots?: ScopeTarget[]; - invalidDependencies?: InvalidDependency[]; -}; - -export type ScopeTarget = { - path: string; - name: string; - line: number; - character: number; -}; - -export type InvalidDependency = { - uri: string; - invalidOnClient?: DependencyTarget; - invalidOnServer?: DependencyTarget; -}; - -export type DependencyTarget = { - uri: string; - target: string; - line: number; - character: number; - length: number; -}; - -export class ScopesDomain implements vscode.Disposable { - private toolingDaemon: JasprToolingDaemon; - private diagnosticCollection: vscode.DiagnosticCollection; - private workspaceSubscriptions: vscode.Disposable; - private configurationSubscriptions: vscode.Disposable; - private statusBarItem: vscode.StatusBarItem | undefined; - - private _onDidChangeScopes: vscode.EventEmitter = - new vscode.EventEmitter(); - public readonly onDidChangeScopes: vscode.Event = - this._onDidChangeScopes.event; - - private scopeResults: ScopeResults = {}; - - constructor(toolingDaemon: JasprToolingDaemon) { - this.toolingDaemon = toolingDaemon; - this.diagnosticCollection = - vscode.languages.createDiagnosticCollection("jaspr"); - this.registerFolders(); - this.workspaceSubscriptions = vscode.workspace.onDidChangeWorkspaceFolders( - (_) => this.registerFolders() - ); - this.toolingDaemon.onDidRestart(() => this.registerFolders()); - this.configurationSubscriptions = vscode.workspace.onDidChangeConfiguration( - (_) => this.updateDiagnostics() - ); - } - - private async registerFolders() { - var folders = await findJasprProjectFolders(); - - this.statusBarItem?.dispose(); - this.statusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 1 - ); - this.statusBarItem.text = `$(loading~spin) Analyzing Jaspr Scopes`; - this.statusBarItem.command = "jaspr.action.showScopeHint"; - - this.toolingDaemon.sendMessage("scopes.register", { folders: folders }); - this.toolingDaemon.onEvent("scopes.result", (results: ScopeResults) => { - this.scopeResults = results; - this._onDidChangeScopes.fire(results); - this.updateDiagnostics(); - }); - - let didReceiveStatus = false; - - this.toolingDaemon.onEvent( - "scopes.status", - (results: Record) => { - if (!this.statusBarItem) { - return; - } - didReceiveStatus = true; - var isBusy = Object.values(results).some((status) => status); - if (isBusy) { - this.statusBarItem!.show(); - this.toolingDaemon.setBusy(true); - } else { - this.statusBarItem!.hide(); - this.toolingDaemon.setBusy(false); - this.statusBarItem!.dispose(); - this.statusBarItem = undefined; - } - } - ); - - setTimeout(() => { - if (!didReceiveStatus && this.statusBarItem) { - this.statusBarItem.dispose(); - this.statusBarItem = undefined; - } - }, 30000); - } - - private updateDiagnostics() { - this.diagnosticCollection.clear(); - - let diagnosticsEnabled = vscode.workspace - .getConfiguration("jaspr.scopes") - .get("showUnsafeImportDiagnostics", true); - if (!diagnosticsEnabled) { - return; - } - - let diagnosticsSeverity = - vscode.DiagnosticSeverity[ - vscode.workspace - .getConfiguration("jaspr.scopes") - .get("unsafeImportDiagnosticSeverity", "Error") - ]; - - for (let path in this.scopeResults) { - const result = this.scopeResults[path]; - if (result.invalidDependencies) { - let diagnostics: vscode.Diagnostic[] = []; - - const messageSuffix = "or change the component scope."; - - for (let dep of result.invalidDependencies) { - if (dep.invalidOnClient) { - const c = dep.invalidOnClient; - const range = new vscode.Range( - new vscode.Position(c.line - 1, c.character - 1), - new vscode.Position(c.line - 1, c.character - 1 + c.length) - ); - - let message = `Unsafe import: '${dep.invalidOnClient.uri}' depends on '${dep.invalidOnClient.target}', which is not available on the client.\nTry using a platform-independent library ${messageSuffix}`; - if (c.uri === "package:jaspr/server.dart") { - message = `Unsafe import: '${c.uri}' is not available on the client.\nTry using 'package:jaspr/jaspr.dart' instead ${messageSuffix}`; - } else if (dep.invalidOnClient.uri === dep.invalidOnClient.target) { - message = `Unsafe import: '${dep.invalidOnClient.uri}' is not available on the client.\nTry using a platform-independent library ${messageSuffix}`; - } - diagnostics.push( - new vscode.Diagnostic(range, message, diagnosticsSeverity) - ); - } - if (dep.invalidOnServer) { - const s = dep.invalidOnServer; - const range = new vscode.Range( - new vscode.Position(s.line - 1, s.character - 1), - new vscode.Position(s.line - 1, s.character - 1 + s.length) - ); - - let message = `Unsafe import: '${dep.invalidOnServer.uri}' depends on '${dep.invalidOnServer.target}', which is not available on the server.\nTry using a platform-independent library ${messageSuffix}`; - if (s.uri === "package:jaspr/client.dart") { - message = `Unsafe import: '${s.uri}' is not available on the server.\nTry using 'package:jaspr/jaspr.dart' instead ${messageSuffix}`; - } else if ( - s.uri === "package:web/web.dart" || - s.uri === "dart:js_interop" - ) { - message = `Unsafe import: '${s.uri}' is not available on the server.\nTry using the 'universal_web' package instead ${messageSuffix}`; - } else if (dep.invalidOnServer.uri === dep.invalidOnServer.target) { - message = `Unsafe import: '${dep.invalidOnServer.uri}' is not available on the server.\nTry using a platform-independent library ${messageSuffix}`; - } - diagnostics.push( - new vscode.Diagnostic(range, message, diagnosticsSeverity) - ); - } - } - - this.diagnosticCollection.set(vscode.Uri.file(path), diagnostics); - } - } - } - - public dispose() { - this.diagnosticCollection.dispose(); - this._onDidChangeScopes.dispose(); - this.workspaceSubscriptions.dispose(); - this.configurationSubscriptions.dispose(); - if (this.statusBarItem) { - this.statusBarItem.dispose(); - this.statusBarItem = undefined; - } - } -} diff --git a/modules/jaspr-code/src/jaspr/tooling_daemon.ts b/modules/jaspr-code/src/jaspr/tooling_daemon.ts deleted file mode 100644 index 9d630ac68..000000000 --- a/modules/jaspr-code/src/jaspr/tooling_daemon.ts +++ /dev/null @@ -1,252 +0,0 @@ -import * as vscode from "vscode"; - -import { - checkJasprInstalled, - checkJasprVersion, -} from "../helpers/install_helper"; -import { spawn } from "child_process"; -import { getOutputChannel, SpawnedProcess, startJaspr } from "../helpers/process_helper"; - -export class JasprToolingDaemon implements vscode.Disposable { - private _disposables: vscode.Disposable[] = []; - - private channel: vscode.OutputChannel | undefined; - private statusItem: vscode.LanguageStatusItem | undefined; - private process: SpawnedProcess | undefined; - - private _onDidRestart: vscode.EventEmitter = - new vscode.EventEmitter(); - public readonly onDidRestart: vscode.Event = this._onDidRestart.event; - - private _isDevMode: boolean = false; - public jasprVersion: string | undefined; - - async start(context: vscode.ExtensionContext): Promise { - const isInstalled = await checkJasprInstalled(); - if (!isInstalled) { - return; - } - - this._disposables.push( - vscode.commands.registerCommand( - "jaspr.restartToolingDaemon", - async () => { - await this._startProcess(); - if (this.process) { - this._onDidRestart.fire(); - } - } - ) - ); - - this.channel = getOutputChannel('jaspr tooling-daemon'); - this.statusItem = vscode.languages.createLanguageStatusItem( - "jaspr_tooling_daemon", - { - language: "dart", - scheme: "file", - } - ); - this.statusItem.name = "Jaspr Tooling Daemon"; - - await this._startProcess(); - } - - public setBusy(busy: boolean) { - if (!this.statusItem) { - return; - } - this.statusItem!.busy = busy; - } - - async _startProcess() { - this.statusItem!.text = "Starting Jaspr Tooling Daemon..."; - this.statusItem!.severity = vscode.LanguageStatusSeverity.Information; - this.statusItem!.command = undefined; - this.statusItem!.busy = true; - - this.process?.kill(); - - this.jasprVersion = await checkJasprVersion(); - - const args = ["tooling-daemon"]; - this.process = startJaspr(args, ""); - - // Give it a moment to start before marking it as not busy. - await new Promise((resolve) => setTimeout(resolve, 500)); - - this.statusItem!.busy = false; - - if (!this.process) { - this.statusItem!.severity = vscode.LanguageStatusSeverity.Error; - this.statusItem!.text = "Failed to start Jaspr Tooling Daemon"; - this.statusItem!.command = { - title: "Restart", - command: "jaspr.restartToolingDaemon", - }; - return; - } - - this.statusItem!.severity = vscode.LanguageStatusSeverity.Information; - this.statusItem!.text = "Jaspr Tooling Daemon"; - this.statusItem!.command = { - title: "Restart", - command: "jaspr.restartToolingDaemon", - }; - - this.process.stdout.setEncoding("utf8"); - this.process.stdout.on("data", (data: Buffer | string) => { - this.handleData(data, false); - }); - this.process.stderr.setEncoding("utf8"); - this.process.stderr.on("data", (data: Buffer | string) => { - this.handleData(data, true); - }); - const p = this.process; - this.process.on("close", (code: any) => { - if (this.statusItem && code) { - this.channel?.appendLine(`Jaspr Tooling Daemon exited with code ${code}`); - - this.statusItem!.severity = vscode.LanguageStatusSeverity.Error; - this.statusItem!.text = `Jaspr Tooling Daemon exited with code ${code}`; - this.statusItem!.command = { - title: "Restart", - command: "jaspr.restartToolingDaemon", - }; - } - - if (this.process === p) { - this.process = undefined; - } - }); - } - - private _currentLine: string = ""; - - handleData(data: Buffer | string, isError: boolean) { - let str = data.toString(); - let lines = str.trim().split("\n"); - for (let line of lines) { - line = line.trim(); - if (line.length === 0) { - continue; - } - - if (line.startsWith("[{")) { - this._currentLine = line; - } else if (this._currentLine.length > 0) { - this._currentLine += line; - } else { - this.channel?.appendLine(`[Unexpected output]: ${line}`); - } - if (this._currentLine.endsWith("}]")) { - let event: any; - let currentLine = this._currentLine; - this._currentLine = ""; - try { - const json = JSON.parse(currentLine); - event = json[0]; - } catch (_) { } - if (event && event.event) { - this.handleEvent(event); - } else if (event && event.id) { - this.handleResponse(event); - } else { - this.channel?.appendLine(`[Unknown message]: ${currentLine}`); - } - } - } - } - - handleEvent(event: any) { - var eventName = event.event; - var params = event.params; - - if (this._eventListeners[eventName]) { - try { - this._eventListeners[eventName](params); - } catch (e) { - console.error(`Error in event listener for ${eventName}:`, e); - } - } else { - if (eventName === "daemon.log") { - this.channel?.appendLine(params.message); - } else { - this.channel?.appendLine(`[${eventName}]: ${JSON.stringify(params)}`); - } - } - } - - handleResponse(response: any) { - var id = response.id; - var result = response.result; - var error = response.error; - - if (this._responseListeners[id]) { - try { - this._responseListeners[id](result, error); - } catch (e) { - this.channel?.appendLine(`Error in response listener for ${id}: ${e}`); - } - } - } - - private _responseListeners: { - [id: string]: (result: any, error?: any) => void; - } = {}; - - async sendMessage( - method: string, - params: any, - timeout?: number - ): Promise { - if (!this.process) { - console.error("Tooling Daemon is not running"); - return null; - } - - const id = Math.random().toString(36).substring(2, 15); - - const message = JSON.stringify([{ method, params, id: id }]) + "\n"; - this.process.stdin.write(message); - - return new Promise((resolve, reject) => { - this._responseListeners[id] = (response: any, error?: any) => { - delete this._responseListeners[id]; - if (error) { - reject(error); - return; - } - resolve(response); - }; - - if (timeout) { - setTimeout(() => { - if (this._responseListeners[id]) { - delete this._responseListeners[id]; - reject( - new Error( - `Timeout waiting for response from tooling daemon. Please try again.` - ) - ); - } - }, timeout); - } - }); - } - - private _eventListeners: { [name: string]: (params: any) => void } = {}; - - onEvent(name: string, callback: (params: any) => void) { - this._eventListeners[name] = callback; - } - - async dispose() { - this.process?.kill(); - this.process = undefined; - this._disposables.forEach((d) => d.dispose()); - this._disposables = []; - this.statusItem?.dispose(); - this.statusItem = undefined; - } -} diff --git a/packages/jaspr_lints/lib/src/rules/styles_ordering_rule.dart b/packages/jaspr_lints/lib/src/rules/styles_ordering_rule.dart index 2ccd8a574..9576d8dc0 100644 --- a/packages/jaspr_lints/lib/src/rules/styles_ordering_rule.dart +++ b/packages/jaspr_lints/lib/src/rules/styles_ordering_rule.dart @@ -5,7 +5,6 @@ import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; import 'package:analyzer/source/source_range.dart'; import '../utils.dart'; @@ -83,11 +82,6 @@ class _StylesVisitor extends SimpleAstVisitor { } } - List findStylesOrder(InterfaceType stylesType) { - final constructor = stylesType.constructors.where((c) => c.name == '').first; - return constructor.formalParameters; - } - static Expression? checkOrder(NodeList args, List params) { int lastSeenParam = -1; diff --git a/packages/jaspr_lints/lib/src/utils/scope_tree.dart b/packages/jaspr_lints/lib/src/utils/scope_tree.dart index a592d0aa2..e584350e6 100644 --- a/packages/jaspr_lints/lib/src/utils/scope_tree.dart +++ b/packages/jaspr_lints/lib/src/utils/scope_tree.dart @@ -57,13 +57,14 @@ class ScopeTree { void writeScopes(String rootPath, {ResourceProvider? resourceProvider}) { late final scopesContent = jsonEncode(buildScopes()); - final scopesPath = path.join(rootPath, '.dart_tool', 'jaspr', 'scopes.json'); if (resourceProvider != null) { + final scopesPath = resourceProvider.pathContext.join(rootPath, '.dart_tool', 'jaspr', 'scopes.json'); final file = resourceProvider.getFile(scopesPath); if (!dirty && file.exists) return; file.writeAsStringSync(scopesContent); } else { + final scopesPath = path.join(rootPath, '.dart_tool', 'jaspr', 'scopes.json'); final file = io.File(scopesPath); if (!dirty && file.existsSync()) return; @@ -118,7 +119,17 @@ class ScopeTree { setScopes(node, {}, {}); } - final output = {}; + int locationIdCounter = 0; + final locationIds = {}; + final locations = {}; + final scopes = {}; + + String putLocation(NodeLocation location) { + final key = '${location.path}:${location.name}'; + final locationId = locationIds.putIfAbsent(key, () => '${locationIdCounter++}'); + locations[locationId] = location.toJson(); + return locationId; + } for (final libraryPath in nodes.keys) { final node = nodes[libraryPath]!; @@ -130,20 +141,19 @@ class ScopeTree { final nodeServerScopes = serverScopes[node] ??= {}; final nodeClientScopes = clientScopes[node] ??= {}; - output[libraryPath] = { - 'components': node.components.map((e) => e.toJson()).toList(), + scopes[libraryPath] = { + 'components': [for (final location in node.components) putLocation(location)], if (nodeServerScopes.isNotEmpty) - 'serverScopeRoots': [ - for (final location in nodeServerScopes) location.toJson(), - ], + 'serverScopeRoots': [for (final location in nodeServerScopes) putLocation(location)], if (nodeClientScopes.isNotEmpty) - 'clientScopeRoots': [ - for (final location in nodeClientScopes) location.toJson(), - ], + 'clientScopeRoots': [for (final location in nodeClientScopes) putLocation(location)], }; } - return output; + return { + 'locations': locations, + 'scopes': scopes, + }; } } @@ -397,7 +407,7 @@ class NodeLocation { NodeLocation(this.path, this.name, this.line, this.character, this.length); Map toJson() { - return {'path': path, 'name': name, 'line': line, 'character': character, 'length': length}; + return {'path': path, 'name': name, 'line': line, 'char': character, 'length': length}; } } diff --git a/packages/jaspr_lints/pubspec.yaml b/packages/jaspr_lints/pubspec.yaml index 054a4884b..660854303 100644 --- a/packages/jaspr_lints/pubspec.yaml +++ b/packages/jaspr_lints/pubspec.yaml @@ -27,4 +27,5 @@ dependencies: dev_dependencies: analyzer_testing: ^0.1.9 + test: ^1.29.0 test_reflective_loader: ^0.4.0 diff --git a/packages/jaspr_lints/test/jaspr_package.dart b/packages/jaspr_lints/test/jaspr_package.dart index 4ab564f45..5a3b38946 100644 --- a/packages/jaspr_lints/test/jaspr_package.dart +++ b/packages/jaspr_lints/test/jaspr_package.dart @@ -15,6 +15,7 @@ extension JasprAnalysisRuleTest on AnalysisRuleTest { class Component { const Component(); factory Component.element({required String tag}) => Component._(); + factory Component.text(String text) => Component._(); } class div extends Component { @@ -23,6 +24,16 @@ extension JasprAnalysisRuleTest on AnalysisRuleTest { class p extends Component { p(List children, {String? id, String? classes}) : super(); } + + abstract class StatelessComponent extends Component { + const StatelessComponent(); + + Component build(BuildContext context); + } + + class BuildContext {} + + void runApp(Component app) {} ''') ..addFile('lib/src/dom/styles/css.dart', r''' import 'styles.dart'; diff --git a/packages/jaspr_lints/test/rules/unsafe_imports_rule_test.dart b/packages/jaspr_lints/test/rules/unsafe_imports_rule_test.dart index 1351a0935..6b1e13d40 100644 --- a/packages/jaspr_lints/test/rules/unsafe_imports_rule_test.dart +++ b/packages/jaspr_lints/test/rules/unsafe_imports_rule_test.dart @@ -1,7 +1,10 @@ // ignore_for_file: non_constant_identifier_names +import 'dart:convert'; + import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; import 'package:jaspr_lints/src/rules/unsafe_imports_rule.dart'; +import 'package:test/test.dart'; import 'package:test_reflective_loader/test_reflective_loader.dart'; import '../jaspr_package.dart'; @@ -11,6 +14,7 @@ void main() { defineReflectiveTests(UnsafeImportsServerTest); defineReflectiveTests(UnsafeImportsClientTest); defineReflectiveTests(UnsafeImportsComponentTest); + defineReflectiveTests(ScopesTest); }); } @@ -193,96 +197,102 @@ class UnsafeImportsComponentTest extends UnsafeImportsBaseTest { } } -/* +@reflectiveTest class ScopesTest extends UnsafeImportsBaseTest { - @override - String get testFileName => 'main.server.dart'; - - void test_scopes_file_is_created() {} -} + Future getScopesFor(Map files) async { + final entrypoints = []; + for (final file in files.entries) { + newFile('$testPackageLibPath/${file.key}', file.value); + if (file.key.endsWith('.server.dart')) { + entrypoints.add(file.key); + } + } + + for (final entrypoint in entrypoints) { + final convertedPath = convertPath('$testPackageLibPath/$entrypoint'); + await resolveFile(convertedPath); + } + + final scopesFile = resourceProvider.getFile(convertPath('$testPackageRootPath/.dart_tool/jaspr/scopes.json')); + return jsonDecode(scopesFile.readAsStringSync()); + } -test('analyzes simple scopes', () async { - projectPath = setUpProject({ - 'main.server.dart': ''' + Future test_simple_scopes() async { + final actualScopes = await getScopesFor({ + 'main.server.dart': ''' import 'package:jaspr/jaspr.dart'; import 'app.dart'; void main() { runApp(App()); } -''', - 'app.dart': ''' + ''', + 'app.dart': ''' import 'package:jaspr/jaspr.dart'; import 'page.dart'; -@client class App extends StatelessComponent { @override Component build(BuildContext context) { return Page(); } } -''', - 'page.dart': ''' + ''', + 'page.dart': ''' import 'package:jaspr/jaspr.dart'; +import 'button.dart'; +@client class Page extends StatelessComponent { @override Component build(BuildContext context) { - return text('Hello, World!'); + return Button(); } } -''', - }); - await domain.registerScopes({ - 'folders': [projectPath], - }); - - await expectScopesResult({ - '$projectPath/lib/app.dart': { - 'components': ['App'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': [ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ], + ''', + 'button.dart': ''' +import 'package:jaspr/jaspr.dart'; + +class Button extends StatelessComponent { + @override + Component build(BuildContext context) { + return text('Click me'); + } +} + ''', + }); + + final expectedScopes = { + 'locations': { + '0': {'path': '$testPackageLibPath/app.dart', 'name': 'App', 'line': 4, 'char': 7, 'length': 3}, + '1': {'path': '$testPackageLibPath/main.server.dart', 'name': 'main', 'line': 4, 'char': 6, 'length': 4}, + '2': {'path': '$testPackageLibPath/page.dart', 'name': 'Page', 'line': 5, 'char': 7, 'length': 4}, + '3': {'path': '$testPackageLibPath/button.dart', 'name': 'Button', 'line': 3, 'char': 7, 'length': 6}, + }, + 'scopes': { + '$testPackageLibPath/app.dart': { + 'components': ['0'], + 'serverScopeRoots': ['1'], }, - '$projectPath/lib/page.dart': { - 'components': ['Page'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': [ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ], + '$testPackageLibPath/page.dart': { + 'components': ['2'], + 'serverScopeRoots': ['1'], + 'clientScopeRoots': ['2'], }, - }); - }); + '$testPackageLibPath/button.dart': { + 'components': ['3'], + 'serverScopeRoots': ['1'], + 'clientScopeRoots': ['2'], + }, + }, + }; + + expect(actualScopes, equals(expectedScopes)); + } - test('analyzes multiple server entrypoints', () async { - projectPath = setUpProject({ - 'main.server.dart': ''' + void test_multi_server_entrypoints() async { + final actualScopes = await getScopesFor({ + 'main.server.dart': ''' import 'package:jaspr/jaspr.dart'; import 'app.dart'; @@ -290,7 +300,7 @@ void main() { runApp(App()); } ''', - 'other.server.dart': ''' + 'other.server.dart': ''' import 'package:jaspr/jaspr.dart'; import 'page.dart'; @@ -298,7 +308,7 @@ void main() { runApp(Page()); } ''', - 'app.dart': ''' + 'app.dart': ''' import 'package:jaspr/jaspr.dart'; import 'page.dart'; @@ -310,7 +320,7 @@ class App extends StatelessComponent { } } ''', - 'page.dart': ''' + 'page.dart': ''' import 'package:jaspr/jaspr.dart'; class Page extends StatelessComponent { @@ -320,128 +330,106 @@ class Page extends StatelessComponent { } } ''', - }); - await domain.registerScopes({ - 'folders': [projectPath], - }); - - await expectScopesResult({ - '$projectPath/lib/app.dart': { - 'components': ['App'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': [ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ], + }); + + final expectedScopes = { + 'locations': { + '0': {'path': '$testPackageLibPath/app.dart', 'name': 'App', 'line': 5, 'char': 7, 'length': 3}, + '1': {'path': '$testPackageLibPath/main.server.dart', 'name': 'main', 'line': 4, 'char': 6, 'length': 4}, + '2': {'path': '$testPackageLibPath/page.dart', 'name': 'Page', 'line': 3, 'char': 7, 'length': 4}, + '3': {'path': '$testPackageLibPath/other.server.dart', 'name': 'main', 'line': 4, 'char': 6, 'length': 4}, + }, + 'scopes': { + '$testPackageLibPath/app.dart': { + 'components': ['0'], + 'serverScopeRoots': ['1'], + 'clientScopeRoots': ['0'], }, - '$projectPath/lib/page.dart': { - 'components': ['Page'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': unorderedEquals([ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - { - 'path': '$projectPath/lib/other.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ]), + '$testPackageLibPath/page.dart': { + 'components': ['2'], + 'serverScopeRoots': unorderedEquals(['1', '3']), + 'clientScopeRoots': ['0'], }, - }); - }); + }, + }; + + expect(actualScopes, equals(expectedScopes)); + } - test('analyzes unallowed imports', () async { - projectPath = setUpProject({ - 'main.server.dart': ''' + void test_multi_client_components() async { + final actualScopes = await getScopesFor({ + 'main.server.dart': ''' import 'package:jaspr/jaspr.dart'; -import 'app.dart'; +import 'home.dart'; +import 'about.dart'; void main() { - runApp(App()); + runApp(Home()); + runApp(About()); } ''', - 'app.dart': ''' -import 'package:jaspr/server.dart'; -import 'package:web/web.dart'; + 'home.dart': ''' +import 'package:jaspr/jaspr.dart'; +import 'button.dart'; @client -class App extends StatelessComponent { +class Home extends StatelessComponent { @override Component build(BuildContext context) { - return text('Hello, World!'); + return Button(); + } +} +''', + 'about.dart': ''' +import 'package:jaspr/jaspr.dart'; +import 'button.dart'; + +@client +class About extends StatelessComponent { + @override + Component build(BuildContext context) { + return Button(); + } +} +''', + 'button.dart': ''' +import 'package:jaspr/jaspr.dart'; + +class Button extends StatelessComponent { + @override + Component build(BuildContext context) { + return text('Click me!'); } } ''', - }); - await domain.registerScopes({ - 'folders': [projectPath], - }); - - await expectScopesResult({ - '$projectPath/lib/app.dart': { - 'components': ['App'], - 'clientScopeRoots': [ - { - 'path': '$projectPath/lib/app.dart', - 'name': 'App', - 'line': 5, - 'character': 7, - }, - ], - 'serverScopeRoots': [ - { - 'path': '$projectPath/lib/main.server.dart', - 'name': 'main', - 'line': 4, - 'character': 6, - }, - ], - 'invalidDependencies': [ - { - 'uri': 'package:jaspr/server.dart', - 'invalidOnClient': { - 'uri': 'package:jaspr/server.dart', - 'target': 'package:jaspr/server.dart', - 'line': 1, - 'character': 1, - 'length': 35, - }, - }, - { - 'uri': 'package:web/web.dart', - 'invalidOnServer': { - 'uri': 'package:web/web.dart', - 'target': 'package:web/web.dart', - 'line': 2, - 'character': 1, - 'length': 30, - }, - }, - ], - }, - }); }); - */ + + final expectedScopes = { + 'locations': { + '0': {'path': '$testPackageLibPath/home.dart', 'name': 'Home', 'line': 5, 'char': 7, 'length': 4}, + '1': {'path': '$testPackageLibPath/main.server.dart', 'name': 'main', 'line': 5, 'char': 6, 'length': 4}, + '2': {'path': '$testPackageLibPath/about.dart', 'name': 'About', 'line': 5, 'char': 7, 'length': 5}, + '3': {'path': '$testPackageLibPath/button.dart', 'name': 'Button', 'line': 3, 'char': 7, 'length': 6}, + }, + 'scopes': { + '$testPackageLibPath/home.dart': { + 'components': ['0'], + 'serverScopeRoots': ['1'], + 'clientScopeRoots': ['0'], + }, + '$testPackageLibPath/about.dart': { + 'components': ['2'], + 'serverScopeRoots': ['1'], + 'clientScopeRoots': ['2'], + }, + '$testPackageLibPath/button.dart': { + 'components': ['3'], + 'serverScopeRoots': ['1'], + 'clientScopeRoots': unorderedEquals(['0', '2']), + }, + }, + }; + + expect(actualScopes, equals(expectedScopes)); + } +} From ad5e87c2a58ed282919bb57b32faffb2a196c921 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Sat, 28 Mar 2026 11:27:15 +0100 Subject: [PATCH 5/6] update changelog --- modules/jaspr-code/CHANGELOG.md | 2 +- modules/jaspr-code/package.json | 2 +- packages/jaspr/CHANGELOG.md | 3 +++ packages/jaspr_lints/CHANGELOG.md | 6 +++++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/jaspr-code/CHANGELOG.md b/modules/jaspr-code/CHANGELOG.md index 7fed407ee..634d8e340 100644 --- a/modules/jaspr-code/CHANGELOG.md +++ b/modules/jaspr-code/CHANGELOG.md @@ -1,4 +1,4 @@ -## 0.4.0 +## 0.4.0-wip - Jaspr is now installed using `dart install jaspr_cli` instead of `dart pub global activate jaspr_cli`. This requires Dart 3.10 or later. diff --git a/modules/jaspr-code/package.json b/modules/jaspr-code/package.json index b72aeee4d..36fb2b0d8 100644 --- a/modules/jaspr-code/package.json +++ b/modules/jaspr-code/package.json @@ -2,7 +2,7 @@ "name": "jaspr-code", "displayName": "Jaspr", "description": "Jaspr framework support for Visual Studio Code", - "version": "0.4.0", + "version": "0.4.0-wip", "repository": { "url": "https://github.com/schultek/jaspr", "directory": "modules/jaspr-code" diff --git a/packages/jaspr/CHANGELOG.md b/packages/jaspr/CHANGELOG.md index caae808f8..31198b168 100644 --- a/packages/jaspr/CHANGELOG.md +++ b/packages/jaspr/CHANGELOG.md @@ -1,6 +1,9 @@ ## Unreleased breaking - Jaspr can now be installed with `dart install jaspr_cli` instead of `dart pub global activate jaspr_cli`. +- Added **Agent Skills** for Jaspr, which can be installed with `jaspr install-skills`. +- Added `jaspr convert-html` command to automatically convert raw HTML to Jaspr code. +- **Breaking** Removed `jaspr tooling-daemon` command. The functionality is now provided by the `jaspr_lints` package. - Improved logging and stability of the CLI. ## 0.22.4 diff --git a/packages/jaspr_lints/CHANGELOG.md b/packages/jaspr_lints/CHANGELOG.md index bd95df670..1a868229d 100644 --- a/packages/jaspr_lints/CHANGELOG.md +++ b/packages/jaspr_lints/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased breaking + +- Added `unsafe_imports` lint rule to detect unsafe platform-specific imports in components depending on where they are rendered (server or client). + ## 0.6.1 - Allow `analyzer` versions 10.x. @@ -21,7 +25,7 @@ ## 0.4.0 -- Added 'prefer_styles_getter' lint and fix. +- Added `prefer_styles_getter` lint and fix. - Update `analyzer_plugin` to `^0.13.0`. ## 0.3.1 From 5e3c8eaebc7b01d2148432fe10e7991c4c13fc3b Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Sat, 28 Mar 2026 11:46:42 +0100 Subject: [PATCH 6/6] update docs --- docs/api/linting.mdx | 10 +++++++--- packages/jaspr_lints/README.md | 1 + packages/jaspr_lints/lib/main.dart | 2 -- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/api/linting.mdx b/docs/api/linting.mdx index be5ff0b0d..6565086cf 100644 --- a/docs/api/linting.mdx +++ b/docs/api/linting.mdx @@ -20,9 +20,7 @@ plugins: styles_ordering: true ``` -After running `dart pub get` you will now see additional Jaspr lints -when invoking code assist on a component function like `div()` or `p()`. - +After running `dart pub get` you now get additional lints and code assists in your IDE or when running `dart analyze`. ## Lint Rules @@ -140,6 +138,12 @@ when invoking code assist on a component function like `div()` or `p()`. + + + Warns about unsafe platform-specific imports in your code depending on whether it is executed on the server or the client. + + + ## Code Assists diff --git a/packages/jaspr_lints/README.md b/packages/jaspr_lints/README.md index 37243d168..27adb74ea 100644 --- a/packages/jaspr_lints/README.md +++ b/packages/jaspr_lints/README.md @@ -24,6 +24,7 @@ After running `dart pub get` you now get additional lints and code assists in yo - Sort children last in HTML components. **(Fix available)** - Sort styles properties. **(Fix available)** - Prefer styles getter over (final) variable. **(Fix available)** +- Avoid unsafe platform-specific imports. ## Code Assists: diff --git a/packages/jaspr_lints/lib/main.dart b/packages/jaspr_lints/lib/main.dart index 95c4a40e9..aa2647ce4 100644 --- a/packages/jaspr_lints/lib/main.dart +++ b/packages/jaspr_lints/lib/main.dart @@ -10,7 +10,6 @@ import 'src/rules/prefer_styles_getter_rule.dart'; import 'src/rules/sort_children_last_rule.dart'; import 'src/rules/styles_ordering_rule.dart'; import 'src/rules/unsafe_imports_rule.dart'; -import 'src/utils/logging.dart'; final plugin = JasprPlugin(); @@ -20,7 +19,6 @@ class JasprPlugin extends Plugin { @override void register(PluginRegistry registry) { - log('REGISTERING'); registry.registerWarningRule(UnsafeImportsRule()); registry.registerLintRule(SortChildrenLastRule());