Implement FileImporter (#57)

This commit is contained in:
Natalie Weizenbaum 2021-12-15 15:40:10 -08:00 committed by GitHub
parent 17f1e69a46
commit 1cbb0c5417
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 439 additions and 44 deletions

View File

@ -1,3 +1,7 @@
## 1.0.0-beta.14
* Support `FileImporter`s.
## 1.0.0-beta.13
* Report a better error message for an empty `CompileRequest.Input.path`.

View File

@ -12,7 +12,8 @@ import 'package:sass_embedded/src/dispatcher.dart';
import 'package:sass_embedded/src/embedded_sass.pb.dart';
import 'package:sass_embedded/src/function_registry.dart';
import 'package:sass_embedded/src/host_callable.dart';
import 'package:sass_embedded/src/importer.dart';
import 'package:sass_embedded/src/importer/file.dart';
import 'package:sass_embedded/src/importer/host.dart';
import 'package:sass_embedded/src/logger.dart';
import 'package:sass_embedded/src/util/length_delimited_transformer.dart';
import 'package:sass_embedded/src/utils.dart';
@ -125,7 +126,7 @@ void main(List<String> args) {
});
}
/// Converts [importer] into an [Importer].
/// Converts [importer] into a [sass.Importer].
sass.Importer? _decodeImporter(
Dispatcher dispatcher,
InboundMessage_CompileRequest request,
@ -135,10 +136,10 @@ sass.Importer? _decodeImporter(
return sass.FilesystemImporter(importer.path);
case InboundMessage_CompileRequest_Importer_Importer.importerId:
return Importer(dispatcher, request.id, importer.importerId);
return HostImporter(dispatcher, request.id, importer.importerId);
case InboundMessage_CompileRequest_Importer_Importer.fileImporterId:
throw "CompileRequest.Importer.fileImporterId is not yet supported";
return FileImporter(dispatcher, request.id, importer.fileImporterId);
case InboundMessage_CompileRequest_Importer_Importer.notSet:
return null;

View File

@ -80,6 +80,11 @@ class Dispatcher {
_dispatchResponse(response.id, response);
break;
case InboundMessage_Message.fileImportResponse:
var response = message.fileImportResponse;
_dispatchResponse(response.id, response);
break;
case InboundMessage_Message.functionCallResponse:
var response = message.functionCallResponse;
_dispatchResponse(response.id, response);
@ -131,6 +136,11 @@ class Dispatcher {
_sendRequest<InboundMessage_ImportResponse>(
OutboundMessage()..importRequest = request);
Future<InboundMessage_FileImportResponse> sendFileImportRequest(
OutboundMessage_FileImportRequest request) =>
_sendRequest<InboundMessage_FileImportResponse>(
OutboundMessage()..fileImportRequest = request);
Future<InboundMessage_FunctionCallResponse> sendFunctionCallRequest(
OutboundMessage_FunctionCallRequest request) =>
_sendRequest<InboundMessage_FunctionCallResponse>(
@ -165,8 +175,12 @@ class Dispatcher {
/// Throws an error if there's no outstanding request with the given [id] or
/// if that request is expecting a different type of response.
void _dispatchResponse<T extends GeneratedMessage>(int id, T response) {
var completer =
id < _outstandingRequests.length ? _outstandingRequests[id] : null;
Completer<GeneratedMessage>? completer;
if (id < _outstandingRequests.length) {
completer = _outstandingRequests[id];
_outstandingRequests[id] = null;
}
if (completer == null) {
throw paramsError(
"Response ID $id doesn't match any outstanding requests.");

View File

@ -0,0 +1,45 @@
// Copyright 2021 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'package:meta/meta.dart';
import 'package:sass_api/sass_api.dart' as sass;
import '../dispatcher.dart';
import '../embedded_sass.pb.dart' hide SourceSpan;
import '../utils.dart';
/// An abstract base class for importers that communicate with the host in some
/// way.
abstract class ImporterBase extends sass.Importer {
/// The [Dispatcher] to which to send requests.
@protected
final Dispatcher dispatcher;
ImporterBase(this.dispatcher);
/// Parses [url] as a [Uri] and throws an error if it's invalid or relative
/// (including root-relative).
///
/// The [field] name is used in the error message if one is thrown.
@protected
Uri parseAbsoluteUrl(String field, String url) {
Uri parsedUrl;
try {
parsedUrl = Uri.parse(url);
} on FormatException catch (error) {
sendAndThrow(paramsError("$field is invalid: $error"));
}
if (parsedUrl.scheme.isNotEmpty) return parsedUrl;
sendAndThrow(paramsError('$field must be absolute, was "$parsedUrl"'));
}
/// Sends [error] to the remote endpoint, and also throws it so that the Sass
/// compilation fails.
@protected
Never sendAndThrow(ProtocolError error) {
dispatcher.sendError(error);
throw "Protocol error: ${error.message}";
}
}

View File

@ -0,0 +1,66 @@
// Copyright 2021 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'dart:cli';
import 'package:sass_api/sass_api.dart' as sass;
import '../dispatcher.dart';
import '../embedded_sass.pb.dart' hide SourceSpan;
import '../utils.dart';
import 'base.dart';
/// A filesystem importer to use for most implementation details of
/// [FileImporter].
///
/// This allows us to avoid duplicating logic between the two importers.
final _filesystemImporter = sass.FilesystemImporter('.');
/// An importer that asks the host to resolve imports in a simplified,
/// file-system-centric way.
class FileImporter extends ImporterBase {
/// The ID of the compilation in which this importer is used.
final int _compilationId;
/// The host-provided ID of the importer to invoke.
final int _importerId;
FileImporter(Dispatcher dispatcher, this._compilationId, this._importerId)
: super(dispatcher);
Uri? canonicalize(Uri url) {
if (url.scheme == 'file') return _filesystemImporter.canonicalize(url);
return waitFor(() async {
var response = await dispatcher
.sendFileImportRequest(OutboundMessage_FileImportRequest()
..compilationId = _compilationId
..importerId = _importerId
..url = url.toString()
..fromImport = fromImport);
switch (response.whichResult()) {
case InboundMessage_FileImportResponse_Result.fileUrl:
var url =
parseAbsoluteUrl("FileImportResponse.file_url", response.fileUrl);
if (url.scheme != 'file') {
sendAndThrow(paramsError(
'FileImportResponse.file_url must be a file: URL, was "$url"'));
}
return _filesystemImporter.canonicalize(url);
case InboundMessage_FileImportResponse_Result.error:
throw response.error;
case InboundMessage_FileImportResponse_Result.notSet:
return null;
}
}());
}
sass.ImporterResult? load(Uri url) => _filesystemImporter.load(url);
String toString() => "FileImporter";
}

View File

@ -6,26 +6,25 @@ import 'dart:cli';
import 'package:sass_api/sass_api.dart' as sass;
import 'dispatcher.dart';
import 'embedded_sass.pb.dart' hide SourceSpan;
import 'utils.dart';
import '../dispatcher.dart';
import '../embedded_sass.pb.dart' hide SourceSpan;
import '../utils.dart';
import 'base.dart';
/// An importer that asks the host to resolve imports.
class Importer extends sass.Importer {
/// The [Dispatcher] to which to send requests.
final Dispatcher _dispatcher;
class HostImporter extends ImporterBase {
/// The ID of the compilation in which this importer is used.
final int _compilationId;
/// The host-provided ID of the importer to invoke.
final int _importerId;
Importer(this._dispatcher, this._compilationId, this._importerId);
HostImporter(Dispatcher dispatcher, this._compilationId, this._importerId)
: super(dispatcher);
Uri? canonicalize(Uri url) {
return waitFor(() async {
var response = await _dispatcher
var response = await dispatcher
.sendCanonicalizeRequest(OutboundMessage_CanonicalizeRequest()
..compilationId = _compilationId
..importerId = _importerId
@ -34,7 +33,7 @@ class Importer extends sass.Importer {
switch (response.whichResult()) {
case InboundMessage_CanonicalizeResponse_Result.url:
return _parseAbsoluteUrl("CanonicalizeResponse.url", response.url);
return parseAbsoluteUrl("CanonicalizeResponse.url", response.url);
case InboundMessage_CanonicalizeResponse_Result.error:
throw response.error;
@ -48,7 +47,7 @@ class Importer extends sass.Importer {
sass.ImporterResult load(Uri url) {
return waitFor(() async {
var response =
await _dispatcher.sendImportRequest(OutboundMessage_ImportRequest()
await dispatcher.sendImportRequest(OutboundMessage_ImportRequest()
..compilationId = _compilationId
..importerId = _importerId
..url = url.toString());
@ -58,7 +57,7 @@ class Importer extends sass.Importer {
return sass.ImporterResult(response.success.contents,
sourceMapUrl: response.success.sourceMapUrl.isEmpty
? null
: _parseAbsoluteUrl("ImportResponse.success.source_map_url",
: parseAbsoluteUrl("ImportResponse.success.source_map_url",
response.success.sourceMapUrl),
syntax: syntaxToSyntax(response.success.syntax));
@ -66,33 +65,10 @@ class Importer extends sass.Importer {
throw response.error;
case InboundMessage_ImportResponse_Result.notSet:
_sendAndThrow(mandatoryError("ImportResponse.result"));
sendAndThrow(mandatoryError("ImportResponse.result"));
}
}());
}
/// Parses [url] as a [Uri] and throws an error if it's invalid or relative
/// (including root-relative).
///
/// The [field] name is used in the error message if one is thrown.
Uri _parseAbsoluteUrl(String field, String url) {
Uri parsedUrl;
try {
parsedUrl = Uri.parse(url);
} on FormatException catch (error) {
_sendAndThrow(paramsError("$field is invalid: $error"));
}
if (parsedUrl.scheme.isNotEmpty) return parsedUrl;
_sendAndThrow(paramsError('$field must be absolute, was "$parsedUrl"'));
}
/// Sends [error] to the remote endpoint, and also throws it so that the Sass
/// compilation fails.
Never _sendAndThrow(ProtocolError error) {
_dispatcher.sendError(error);
throw "Protocol error: ${error.message}";
}
String toString() => "HostImporter";
}

View File

@ -1,7 +1,6 @@
name: sass_embedded
version: 1.0.0-beta.13
version: 1.0.0-beta.14
description: An implementation of the Sass embedded protocol using Dart Sass.
author: Sass Team
homepage: https://github.com/sass/dart-sass-embedded
environment:

View File

@ -0,0 +1,280 @@
// Copyright 2021 Google LLC. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
import 'package:sass_embedded/src/embedded_sass.pb.dart';
import 'package:sass_embedded/src/utils.dart';
import 'embedded_process.dart';
import 'utils.dart';
void main() {
late EmbeddedProcess process;
setUp(() async {
process = await EmbeddedProcess.start();
});
group("emits a protocol error", () {
late OutboundMessage_FileImportRequest request;
setUp(() async {
process.inbound.add(compileString("@import 'other'", importers: [
InboundMessage_CompileRequest_Importer()..fileImporterId = 1
]));
request = getFileImportRequest(await process.outbound.next);
});
test("for a response without a corresponding request ID", () async {
process.inbound.add(InboundMessage()
..fileImportResponse =
(InboundMessage_FileImportResponse()..id = request.id + 1));
await expectParamsError(
process,
errorId,
"Response ID ${request.id + 1} doesn't match any outstanding "
"requests.");
await process.kill();
});
test("for a response that doesn't match the request type", () async {
process.inbound.add(InboundMessage()
..canonicalizeResponse =
(InboundMessage_CanonicalizeResponse()..id = request.id));
await expectParamsError(
process,
errorId,
"Request ID ${request.id} doesn't match response type "
"InboundMessage_CanonicalizeResponse.");
await process.kill();
});
group("for a FileImportResponse with a URL", () {
test("that's empty", () async {
process.inbound.add(InboundMessage()
..fileImportResponse = (InboundMessage_FileImportResponse()
..id = request.id
..fileUrl = ""));
await _expectImportParamsError(
process, 'FileImportResponse.file_url must be absolute, was ""');
await process.kill();
});
test("that's relative", () async {
process.inbound.add(InboundMessage()
..fileImportResponse = (InboundMessage_FileImportResponse()
..id = request.id
..fileUrl = "foo"));
await _expectImportParamsError(
process, 'FileImportResponse.file_url must be absolute, was "foo"');
await process.kill();
});
test("that's not file:", () async {
process.inbound.add(InboundMessage()
..fileImportResponse = (InboundMessage_FileImportResponse()
..id = request.id
..fileUrl = "other:foo"));
await _expectImportParamsError(process,
'FileImportResponse.file_url must be a file: URL, was "other:foo"');
await process.kill();
});
});
});
group("includes in FileImportRequest", () {
var compilationId = 1234;
var importerId = 5679;
late OutboundMessage_FileImportRequest request;
setUp(() async {
process.inbound.add(
compileString("@import 'other'", id: compilationId, importers: [
InboundMessage_CompileRequest_Importer()..fileImporterId = importerId
]));
request = getFileImportRequest(await process.outbound.next);
});
test("the same compilationId as the compilation", () async {
expect(request.compilationId, equals(compilationId));
await process.kill();
});
test("a known importerId", () async {
expect(request.importerId, equals(importerId));
await process.kill();
});
test("the imported URL", () async {
expect(request.url, equals("other"));
await process.kill();
});
test("whether the import came from an @import", () async {
expect(request.fromImport, isTrue);
await process.kill();
});
});
test("errors cause compilation to fail", () async {
process.inbound.add(compileString("@import 'other'", importers: [
InboundMessage_CompileRequest_Importer()..fileImporterId = 1
]));
var request = getFileImportRequest(await process.outbound.next);
process.inbound.add(InboundMessage()
..fileImportResponse = (InboundMessage_FileImportResponse()
..id = request.id
..error = "oh no"));
var failure = getCompileFailure(await process.outbound.next);
expect(failure.message, equals('oh no'));
expect(failure.span.text, equals("'other'"));
expect(failure.stackTrace, equals('- 1:9 root stylesheet\n'));
await process.kill();
});
test("null results count as not found", () async {
process.inbound.add(compileString("@import 'other'", importers: [
InboundMessage_CompileRequest_Importer()..fileImporterId = 1
]));
var request = getFileImportRequest(await process.outbound.next);
process.inbound.add(InboundMessage()
..fileImportResponse =
(InboundMessage_FileImportResponse()..id = request.id));
var failure = getCompileFailure(await process.outbound.next);
expect(failure.message, equals("Can't find stylesheet to import."));
expect(failure.span.text, equals("'other'"));
await process.kill();
});
group("attempts importers in order", () {
test("with multiple file importers", () async {
process.inbound.add(compileString("@import 'other'", importers: [
for (var i = 0; i < 10; i++)
InboundMessage_CompileRequest_Importer()..fileImporterId = i
]));
for (var i = 0; i < 10; i++) {
var request = getFileImportRequest(await process.outbound.next);
expect(request.importerId, equals(i));
process.inbound.add(InboundMessage()
..fileImportResponse =
(InboundMessage_FileImportResponse()..id = request.id));
}
await process.kill();
});
test("with a mixture of file and normal importers", () async {
process.inbound.add(compileString("@import 'other'", importers: [
for (var i = 0; i < 10; i++)
if (i % 2 == 0)
InboundMessage_CompileRequest_Importer()..fileImporterId = i
else
InboundMessage_CompileRequest_Importer()..importerId = i
]));
for (var i = 0; i < 10; i++) {
if (i % 2 == 0) {
var request = getFileImportRequest(await process.outbound.next);
expect(request.importerId, equals(i));
process.inbound.add(InboundMessage()
..fileImportResponse =
(InboundMessage_FileImportResponse()..id = request.id));
} else {
var request = getCanonicalizeRequest(await process.outbound.next);
expect(request.importerId, equals(i));
process.inbound.add(InboundMessage()
..canonicalizeResponse =
(InboundMessage_CanonicalizeResponse()..id = request.id));
}
}
await process.kill();
});
});
test("tries resolved URL as a relative path first", () async {
await d.file("upstream.scss", "a {b: c}").create();
await d.file("midstream.scss", "@import 'upstream';").create();
process.inbound.add(compileString("@import 'midstream'", importers: [
for (var i = 0; i < 10; i++)
InboundMessage_CompileRequest_Importer()..fileImporterId = i
]));
for (var i = 0; i < 5; i++) {
var request = getFileImportRequest(await process.outbound.next);
expect(request.url, equals("midstream"));
expect(request.importerId, equals(i));
process.inbound.add(InboundMessage()
..fileImportResponse =
(InboundMessage_FileImportResponse()..id = request.id));
}
var request = getFileImportRequest(await process.outbound.next);
expect(request.importerId, equals(5));
process.inbound.add(InboundMessage()
..fileImportResponse = (InboundMessage_FileImportResponse()
..id = request.id
..fileUrl = p.toUri(d.path("midstream")).toString()));
await expectLater(process.outbound, emits(isSuccess("a { b: c; }")));
await process.kill();
});
group("handles an importer for a string compile request", () {
setUp(() async {
await d.file("other.scss", "a {b: c}").create();
});
test("without a base URL", () async {
process.inbound.add(compileString("@import 'other'",
importer: InboundMessage_CompileRequest_Importer()
..fileImporterId = 1));
var request = getFileImportRequest(await process.outbound.next);
expect(request.url, equals("other"));
process.inbound.add(InboundMessage()
..fileImportResponse = (InboundMessage_FileImportResponse()
..id = request.id
..fileUrl = p.toUri(d.path("other")).toString()));
await expectLater(process.outbound, emits(isSuccess("a { b: c; }")));
await process.kill();
});
test("with a base URL", () async {
process.inbound.add(compileString("@import 'other'",
url: p.toUri(d.path("input")).toString(),
importer: InboundMessage_CompileRequest_Importer()
..fileImporterId = 1));
await expectLater(process.outbound, emits(isSuccess("a { b: c; }")));
await process.kill();
});
});
}
/// Asserts that [process] emits a [ProtocolError] params error with the given
/// [message] on its protobuf stream and causes the compilation to fail.
Future<void> _expectImportParamsError(EmbeddedProcess process, message) async {
await expectLater(process.outbound,
emits(isProtocolError(errorId, ProtocolErrorType.PARAMS, message)));
var failure = getCompileFailure(await process.outbound.next);
expect(failure.message, equals('Protocol error: $message'));
expect(failure.span.text, equals("'other'"));
}

View File

@ -103,6 +103,16 @@ OutboundMessage_ImportRequest getImportRequest(value) {
return message.importRequest;
}
/// Asserts that [message] is an [OutboundMessage] with a `FileImportRequest`
/// and returns it.
OutboundMessage_FileImportRequest getFileImportRequest(value) {
expect(value, isA<OutboundMessage>());
var message = value as OutboundMessage;
expect(message.hasFileImportRequest(), isTrue,
reason: "Expected $message to have a FileImportRequest");
return message.fileImportRequest;
}
/// Asserts that [message] is an [OutboundMessage] with a
/// `FunctionCallRequest` and returns it.
OutboundMessage_FunctionCallRequest getFunctionCallRequest(value) {