diff --git a/lib/src/callable.dart b/lib/src/callable.dart index ac9bcdb5..04b0439e 100644 --- a/lib/src/callable.dart +++ b/lib/src/callable.dart @@ -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. diff --git a/lib/src/functions.dart b/lib/src/functions.dart index 6773362f..3a1c27bb 100644 --- a/lib/src/functions.dart +++ b/lib/src/functions.dart @@ -555,7 +555,7 @@ final List 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 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 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. diff --git a/lib/src/value/external/string.dart b/lib/src/value/external/string.dart index d6cd22c2..8a35b874 100644 --- a/lib/src/value/external/string.dart +++ b/lib/src/value/external/string.dart @@ -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]); } diff --git a/lib/src/value/string.dart b/lib/src/value/string.dart index dad59506..fc9305f4 100644 --- a/lib/src/value/string.dart +++ b/lib/src/value/string.dart @@ -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(ValueVisitor 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"); } diff --git a/test/dart_api/value/string_test.dart b/test/dart_api/value/string_test.dart index 3adc46a6..7109c7e8 100644 --- a/test/dart_api/value/string_test.dart +++ b/test/dart_api/value/string_test.dart @@ -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();