Add SassString index helpers

We aren't using these in our own functions because, for
backwards-compatibility, these functions don't throw errors for
invalid indices.
This commit is contained in:
Natalie Weizenbaum 2018-01-14 15:39:00 -08:00
parent c88912fc4c
commit 123fe55017
5 changed files with 245 additions and 6 deletions

View File

@ -45,6 +45,22 @@ export 'callable/user_defined.dart';
///
/// * When in doubt, lists should default to comma-separated, strings should
/// default to quoted, and number should default to unitless.
///
/// * In Sass, lists and strings use one-based indexing and use negative indices
/// to index from the end of value. Functions should follow these conventions.
/// The [Value.sassIndexToListIndex] and [SassString.sassIndexToStringIndex]
/// methods can be used to do this automatically.
///
/// * String indexes in Sass refer to Unicode code points while Dart string
/// indices refer to UTF-16 code units. For example, the character U+1F60A,
/// Smiling Face With Smiling Eyes, is a single Unicode code point but is
/// represented in UTF-16 as two code units (`0xD83D` and `0xDE0A`). So in
/// Dart, `"a😊b".codeUnitAt(1)` returns `0xD83D`, whereas in Sass
/// `str-slice("a😊b", 1, 1)` returns `"😊"`. Functions should follow this
/// convention. The [SassString.sassIndexToStringIndex] and
/// [SassString.sassIndexToRuneIndex] methods can be used to do this
/// automatically, and the [SassString.sassLength] getter can be used to
/// access a string's length in code points.
abstract class Callable extends AsyncCallable {
/// Creates a callable with the given [name] and [arguments] that runs
/// [callback] when called.

View File

@ -555,7 +555,7 @@ final List<BuiltInCallable> coreFunctions = new UnmodifiableListView([
new BuiltInCallable("str-length", r"$string", (arguments) {
var string = arguments[0].assertString("string");
return new SassNumber(string.text.runes.length);
return new SassNumber(string.sassLength);
}),
new BuiltInCallable("str-insert", r"$string, $insert, $index", (arguments) {
@ -572,7 +572,7 @@ final List<BuiltInCallable> coreFunctions = new UnmodifiableListView([
// negative.
if (indexInt < 0) indexInt++;
var codepointIndex = _codepointForIndex(indexInt, string.text.runes.length);
var codepointIndex = _codepointForIndex(indexInt, string.sassLength);
var codeUnitIndex =
codepointIndexToCodeUnitIndex(string.text, codepointIndex);
@ -600,7 +600,7 @@ final List<BuiltInCallable> coreFunctions = new UnmodifiableListView([
start.assertNoUnits("start");
end.assertNoUnits("end");
var lengthInCodepoints = string.text.runes.length;
var lengthInCodepoints = string.sassLength;
// No matter what the start index is, an end index of 0 will produce an
// empty string.

View File

@ -27,6 +27,19 @@ abstract class SassString extends Value {
/// Whether this string has quotes.
bool get hasQuotes;
/// Sass's notion of the length of this string.
///
/// Sass treats strings as a series of Unicode code points while Dart treats
/// them as a series of UTF-16 code units. For example, the character U+1F60A,
/// Smiling Face With Smiling Eyes, is a single Unicode code point but is
/// represented in UTF-16 as two code units (`0xD83D` and `0xDE0A`). So in
/// Dart, `"a😊b".length` returns `4`, whereas in Sass `str-length("a😊b")`
/// returns `3`.
///
/// This returns the same value as `text.runes.length`, but it's more
/// efficient.
int get sassLength;
/// Creates an empty string.
///
/// The [quotes] argument defaults to `false`.
@ -36,4 +49,40 @@ abstract class SassString extends Value {
///
/// The [quotes] argument defaults to `false`.
factory SassString(String text, {bool quotes}) = internal.SassString;
/// Converts [sassIndex] into a Dart-style index into [text].
///
/// Sass indexes are one-based, while Dart indexes are zero-based. Sass
/// indexes may also be negative in order to index from the end of the string.
///
/// In addition, Sass indices refer to Unicode code points while Dart string
/// indices refer to UTF-16 code units. For example, the character U+1F60A,
/// Smiling Face With Smiling Eyes, is a single Unicode code point but is
/// represented in UTF-16 as two code units (`0xD83D` and `0xDE0A`). So in
/// Dart, `"a😊b".codeUnitAt(1)` returns `0xD83D`, whereas in Sass
/// `str-slice("a😊b", 1, 1)` returns `"😊"`.
///
/// This function converts Sass's code point indexes to Dart's code unit
/// indexes. This means it's O(n) in the length of [text]. See also
/// [sassIndexToRuneIndex], which is O(1) and returns an index into the
/// string's code points (accessible via `text.runes`).
///
/// Throws a [SassScriptException] if [sassIndex] isn't a number, if that
/// number isn't an integer, or if that integer isn't a valid index for this
/// string. If [sassIndex] came from a function argument, [name] is the
/// argument name (without the `$`). It's used for error reporting.
int sassIndexToStringIndex(Value sassIndex, [String name]);
/// Converts [sassIndex] into a Dart-style index into [text]`.runes`.
///
/// Sass indexes are one-based, while Dart indexes are zero-based. Sass
/// indexes may also be negative in order to index from the end of the string.
///
/// See also [sassIndexToStringIndex], which an index into [text] directly.
///
/// Throws a [SassScriptException] if [sassIndex] isn't a number, if that
/// number isn't an integer, or if that integer isn't a valid index for this
/// string. If [sassIndex] came from a function argument, [name] is the
/// argument name (without the `$`). It's used for error reporting.
int sassIndexToRuneIndex(Value sassIndex, [String name]);
}

View File

@ -4,9 +4,11 @@
import 'package:charcode/charcode.dart';
import '../exception.dart';
import '../util/character.dart';
import '../visitor/interface/value.dart';
import '../utils.dart';
import '../value.dart';
import '../visitor/interface/value.dart';
import 'external/value.dart' as ext;
/// A quoted empty string, returned by [SassString.empty].
@ -20,6 +22,13 @@ class SassString extends Value implements ext.SassString {
final bool hasQuotes;
int get sassLength {
_sassLength ??= text.runes.length;
return _sassLength;
}
int _sassLength;
bool get isSpecialNumber {
if (hasQuotes) return false;
if (text.length < "calc(_)".length) return false;
@ -56,6 +65,22 @@ class SassString extends Value implements ext.SassString {
SassString(this.text, {bool quotes: false}) : hasQuotes = quotes;
int sassIndexToStringIndex(ext.Value sassIndex, [String name]) =>
codepointIndexToCodeUnitIndex(
text, sassIndexToRuneIndex(sassIndex, name));
int sassIndexToRuneIndex(ext.Value sassIndex, [String name]) {
var index = sassIndex.assertNumber(name).assertInt(name);
if (index == 0) throw _exception("String index may not be 0.", name);
if (index.abs() > sassLength) {
throw _exception(
"Invalid index $sassIndex for a string with $sassLength characters.",
name);
}
return index < 0 ? sassLength + index : index - 1;
}
T accept<T>(ValueVisitor<T> visitor) => visitor.visitString(this);
SassString assertString([String name]) => this;
@ -71,4 +96,8 @@ class SassString extends Value implements ext.SassString {
bool operator ==(other) => other is SassString && text == other.text;
int get hashCode => text.hashCode;
/// Throws a [SassScriptException] with the given [message].
SassScriptException _exception(String message, [String name]) =>
new SassScriptException(name == null ? message : "\$$name: $message");
}

View File

@ -11,7 +11,7 @@ import 'package:sass/sass.dart';
import 'utils.dart';
main() {
group("an unquoted string", () {
group("an unquoted ASCII string", () {
SassString value;
setUp(() => value = parseValue("foobar") as SassString);
@ -39,9 +39,91 @@ main() {
expect(value.assertMap, throwsSassScriptException);
expect(value.assertNumber, throwsSassScriptException);
});
test("sassLength returns the length", () {
expect(value.sassLength, equals(6));
});
group("sassIndexToStringIndex()", () {
test("converts a positive index to a Dart index", () {
expect(value.sassIndexToStringIndex(new SassNumber(1)), equals(0));
expect(value.sassIndexToStringIndex(new SassNumber(2)), equals(1));
expect(value.sassIndexToStringIndex(new SassNumber(3)), equals(2));
expect(value.sassIndexToStringIndex(new SassNumber(4)), equals(3));
expect(value.sassIndexToStringIndex(new SassNumber(5)), equals(4));
expect(value.sassIndexToStringIndex(new SassNumber(6)), equals(5));
});
test("converts a negative index to a Dart index", () {
expect(value.sassIndexToStringIndex(new SassNumber(-1)), equals(5));
expect(value.sassIndexToStringIndex(new SassNumber(-2)), equals(4));
expect(value.sassIndexToStringIndex(new SassNumber(-3)), equals(3));
expect(value.sassIndexToStringIndex(new SassNumber(-4)), equals(2));
expect(value.sassIndexToStringIndex(new SassNumber(-5)), equals(1));
expect(value.sassIndexToStringIndex(new SassNumber(-6)), equals(0));
});
test("rejects a non-number", () {
expect(() => value.sassIndexToStringIndex(new SassString("foo")),
throwsSassScriptException);
});
test("rejects a non-integer", () {
expect(() => value.sassIndexToStringIndex(new SassNumber(1.1)),
throwsSassScriptException);
});
test("rejects invalid indices", () {
expect(() => value.sassIndexToStringIndex(new SassNumber(0)),
throwsSassScriptException);
expect(() => value.sassIndexToStringIndex(new SassNumber(7)),
throwsSassScriptException);
expect(() => value.sassIndexToStringIndex(new SassNumber(-7)),
throwsSassScriptException);
});
});
group("sassIndexToRuneIndex()", () {
test("converts a positive index to a Dart index", () {
expect(value.sassIndexToRuneIndex(new SassNumber(1)), equals(0));
expect(value.sassIndexToRuneIndex(new SassNumber(2)), equals(1));
expect(value.sassIndexToRuneIndex(new SassNumber(3)), equals(2));
expect(value.sassIndexToRuneIndex(new SassNumber(4)), equals(3));
expect(value.sassIndexToRuneIndex(new SassNumber(5)), equals(4));
expect(value.sassIndexToRuneIndex(new SassNumber(6)), equals(5));
});
test("converts a negative index to a Dart index", () {
expect(value.sassIndexToRuneIndex(new SassNumber(-1)), equals(5));
expect(value.sassIndexToRuneIndex(new SassNumber(-2)), equals(4));
expect(value.sassIndexToRuneIndex(new SassNumber(-3)), equals(3));
expect(value.sassIndexToRuneIndex(new SassNumber(-4)), equals(2));
expect(value.sassIndexToRuneIndex(new SassNumber(-5)), equals(1));
expect(value.sassIndexToRuneIndex(new SassNumber(-6)), equals(0));
});
test("rejects a non-number", () {
expect(() => value.sassIndexToRuneIndex(new SassString("foo")),
throwsSassScriptException);
});
test("rejects a non-integer", () {
expect(() => value.sassIndexToRuneIndex(new SassNumber(1.1)),
throwsSassScriptException);
});
test("rejects invalid indices", () {
expect(() => value.sassIndexToRuneIndex(new SassNumber(0)),
throwsSassScriptException);
expect(() => value.sassIndexToRuneIndex(new SassNumber(7)),
throwsSassScriptException);
expect(() => value.sassIndexToRuneIndex(new SassNumber(-7)),
throwsSassScriptException);
});
});
});
group("a quoted string", () {
group("a quoted ASCII string", () {
SassString value;
setUp(() => value = parseValue('"foobar"') as SassString);
@ -59,6 +141,69 @@ main() {
});
});
group("an unquoted Unicde", () {
SassString value;
setUp(() => value = parseValue("a👭b👬c") as SassString);
test("sassLength returns the length", () {
expect(value.sassLength, equals(5));
});
group("sassIndexToStringIndex()", () {
test("converts a positive index to a Dart index", () {
expect(value.sassIndexToStringIndex(new SassNumber(1)), equals(0));
expect(value.sassIndexToStringIndex(new SassNumber(2)), equals(1));
expect(value.sassIndexToStringIndex(new SassNumber(3)), equals(3));
expect(value.sassIndexToStringIndex(new SassNumber(4)), equals(4));
expect(value.sassIndexToStringIndex(new SassNumber(5)), equals(6));
});
test("converts a negative index to a Dart index", () {
expect(value.sassIndexToStringIndex(new SassNumber(-1)), equals(6));
expect(value.sassIndexToStringIndex(new SassNumber(-2)), equals(4));
expect(value.sassIndexToStringIndex(new SassNumber(-3)), equals(3));
expect(value.sassIndexToStringIndex(new SassNumber(-4)), equals(1));
expect(value.sassIndexToStringIndex(new SassNumber(-5)), equals(0));
});
test("rejects invalid indices", () {
expect(() => value.sassIndexToStringIndex(new SassNumber(0)),
throwsSassScriptException);
expect(() => value.sassIndexToStringIndex(new SassNumber(6)),
throwsSassScriptException);
expect(() => value.sassIndexToStringIndex(new SassNumber(-6)),
throwsSassScriptException);
});
});
group("sassIndexToRuneIndex()", () {
test("converts a positive index to a Dart index", () {
expect(value.sassIndexToRuneIndex(new SassNumber(1)), equals(0));
expect(value.sassIndexToRuneIndex(new SassNumber(2)), equals(1));
expect(value.sassIndexToRuneIndex(new SassNumber(3)), equals(2));
expect(value.sassIndexToRuneIndex(new SassNumber(4)), equals(3));
expect(value.sassIndexToRuneIndex(new SassNumber(5)), equals(4));
});
test("converts a negative index to a Dart index", () {
expect(value.sassIndexToRuneIndex(new SassNumber(-1)), equals(4));
expect(value.sassIndexToRuneIndex(new SassNumber(-2)), equals(3));
expect(value.sassIndexToRuneIndex(new SassNumber(-3)), equals(2));
expect(value.sassIndexToRuneIndex(new SassNumber(-4)), equals(1));
expect(value.sassIndexToRuneIndex(new SassNumber(-5)), equals(0));
});
test("rejects invalid indices", () {
expect(() => value.sassIndexToRuneIndex(new SassNumber(0)),
throwsSassScriptException);
expect(() => value.sassIndexToRuneIndex(new SassNumber(6)),
throwsSassScriptException);
expect(() => value.sassIndexToRuneIndex(new SassNumber(-6)),
throwsSassScriptException);
});
});
});
group("new SassString.empty()", () {
test("creates an empty unquoted string", () {
var string = new SassString.empty();