// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Class which allows construction of annotated strings.
*/
import {TestImportManager} from '/common/testing/test_import_manager.js';
type Annotation = any;
/** The serialized format of a spannable. */
export interface SerializedSpannable {
string: string;
spans: SerializedSpan[];
}
export class Spannable {
/** Underlying string. */
private string_: string;
/** Spans (annotations). */
private spans_: SpanStruct[] = [];
/**
* @param stringValue Initial value of the spannable.
* @param annotation Initial annotation for the entire string.
*/
constructor(stringValue?: string|Spannable, annotation?: Annotation) {
this.string_ = stringValue instanceof Spannable ? '' : stringValue || '';
// Append the initial spannable.
if (stringValue instanceof Spannable) {
this.append(stringValue);
}
// Optionally annotate the entire string.
if (annotation !== undefined) {
const len = this.string_.length;
this.spans_.push({value: annotation, start: 0, end: len});
}
}
toString(): string {
return this.string_;
}
/** @return The length of the string */
get length(): number {
return this.string_.length;
}
/**
* Adds a span to some region of the string.
* @param value Annotation.
* @param start Starting index (inclusive).
* @param end Ending index (exclusive).
*/
setSpan(value: Annotation, start: number, end: number): void {
this.removeSpan(value);
this.setSpanInternal(value, start, end);
}
/**
* @param value Annotation.
* @param start Starting index (inclusive).
* @param end Ending index (exclusive).
*/
protected setSpanInternal(
value: Annotation, start: number, end: number): void {
if (0 <= start && start <= end && end <= this.string_.length) {
// Zero-length spans are explicitly allowed, because it is possible to
// query for position by annotation as well as the reverse.
this.spans_.push({value, start, end});
this.spans_.sort(function(a, b) {
let ret = a.start - b.start;
if (ret === 0) {
ret = a.end - b.end;
}
return ret;
});
} else {
throw new RangeError(
'span out of range (start=' + start + ', end=' + end +
', len=' + this.string_.length + ')');
}
}
/**
* Removes a span.
* @param value Annotation.
*/
removeSpan(value: Annotation): void {
for (let i = this.spans_.length - 1; i >= 0; i--) {
if (this.spans_[i].value === value) {
this.spans_.splice(i, 1);
}
}
}
/**
* Appends another Spannable or string to this one.
* @param other String or spannable to concatenate.
*/
append(other: string | Spannable): void {
if (other instanceof Spannable) {
const otherSpannable = other as Spannable;
const originalLength = this.length;
this.string_ += otherSpannable.string_;
other.spans_.forEach(
span => this.setSpan(
span.value, span.start + originalLength,
span.end + originalLength));
} else if (typeof other === 'string') {
this.string_ += other;
}
}
/**
* Returns the first value matching a position.
* @param position Position to query.
* @return Value annotating that position, or undefined if none is
* found.
*/
getSpan(position: number): Annotation {
return valueOfSpan(this.spans_.find(spanCoversPosition(position)));
}
/**
* Returns the first span value which is an instance of a given constructor.
* @param constructor Constructor.
* @return Object if found; undefined otherwise.
*/
getSpanInstanceOf(constructor: Function): Annotation {
return valueOfSpan(this.spans_.find(spanInstanceOf(constructor)));
}
/**
* Returns all span values which are an instance of a given constructor.
* Spans are returned in the order of their starting index and ending index
* for spans with equals tarting indices.
* @param constructor Constructor.
* @return Array of object.
*/
getSpansInstanceOf(constructor: Function): Annotation[] {
return (this.spans_.filter(spanInstanceOf(constructor)).map(valueOfSpan));
}
/**
* Returns all spans matching a position.
* @param position Position to query.
* @return Values annotating that position.
*/
getSpans(position: number): Annotation[] {
return (this.spans_.filter(spanCoversPosition(position)).map(valueOfSpan));
}
/**
* Returns whether a span is contained in this object.
* @param value Annotation.
*/
hasSpan(value: Annotation): boolean {
return this.spans_.some(spanValueIs(value));
}
/**
* Returns the start of the requested span. Throws if the span doesn't exist
* in this object.
* @param value Annotation.
*/
getSpanStart(value: Annotation): number {
return this.getSpanByValueOrThrow_(value).start;
}
/**
* Returns the end of the requested span. Throws if the span doesn't exist
* in this object.
* @param value Annotation.
*/
getSpanEnd(value: Annotation): number {
return this.getSpanByValueOrThrow_(value).end;
}
/**
* @param value Annotation.
*/
getSpanIntervals(value: Annotation): Interval[] {
return this.spans_.filter(span => span.value === value).map(span => {
return {start: span.start, end: span.end};
});
}
/**
* Returns the number of characters covered by the given span. Throws if
* the span is not in this object.
*/
getSpanLength(value: Annotation): number {
const span = this.getSpanByValueOrThrow_(value);
return span.end - span.start;
}
/**
* Gets the internal object for a span or throws if the span doesn't exist.
* @param value The annotation.
*/
private getSpanByValueOrThrow_(value: Annotation): SpanStruct {
const span = this.spans_.find(spanValueIs(value));
if (span) {
return span;
}
throw new Error('Span ' + value + ' doesn\'t exist in spannable');
}
/**
* Returns a substring of this spannable.
* Note that while similar to String#substring, this function is much less
* permissive about its arguments. It does not accept arguments in the wrong
* order or out of bounds.
*
* @param start Start index, inclusive.
* @param end End index, exclusive.
* If excluded, the length of the string is used instead.
* @return Substring requested.
*/
substring(start: number, end?: number): Spannable {
end = end !== undefined ? end : this.string_.length;
if (start < 0 || end > this.string_.length || start > end) {
throw new RangeError('substring indices out of range');
}
const result = new Spannable(this.string_.substring(start, end));
this.spans_.forEach(span => {
if (span.start <= end && span.end >= start) {
const newStart = Math.max(0, span.start - start);
const newEnd = Math.min(end - start, span.end - start);
result.spans_.push({value: span.value, start: newStart, end: newEnd});
}
});
return result;
}
/**
* Trims whitespace from the beginning.
* @return String with whitespace removed.
*/
trimLeft(): Spannable {
return this.trim_(true, false);
}
/**
* Trims whitespace from the end.
* @return String with whitespace removed.
*/
trimRight(): Spannable {
return this.trim_(false, true);
}
/**
* Trims whitespace from the beginning and end.
* @return String with whitespace removed.
*/
trim(): Spannable {
return this.trim_(true, true);
}
/**
* Trims whitespace from either the beginning and end or both.
* @param trimStart Trims whitespace from the start of a string.
* @param trimEnd Trims whitespace from the end of a string.
* @return String with whitespace removed.
*/
private trim_(trimStart: boolean, trimEnd: boolean): Spannable {
if (!trimStart && !trimEnd) {
return this;
}
// Special-case whitespace-only strings, including the empty string.
// As an arbitrary decision, we treat this as trimming the whitespace off
// the end, rather than the beginning, of the string.
// This choice affects which spans are kept.
if (/^\s*$/.test(this.string_)) {
return this.substring(0, 0);
}
// Otherwise, we have at least one non-whitespace character to use as an
// anchor when trimming.
// TODO(b/314203187): Not null asserted, check that this is correct.
const trimmedStart = trimStart ? this.string_.match(/^\s*/)![0].length : 0;
const trimmedEnd =
trimEnd ? this.string_.match(/\s*$/)!.index : this.string_.length;
return this.substring(trimmedStart, trimmedEnd);
}
/**
* Returns this spannable to a json serializable form, including the text
* and span objects whose types have been registered with
* registerSerializableSpan or registerStatelessSerializableSpan.
* @return the json serializable form.
*/
toJson(): SerializedSpannable {
const spans: SerializedSpan[] = [];
this.spans_.forEach(span => {
const serializeInfo =
serializableSpansByConstructor.get(span.value.constructor);
if (serializeInfo) {
const spanObj: SerializedSpan = {
type: serializeInfo.name,
start: span.start,
end: span.end,
value: undefined,
};
if (serializeInfo.toJson) {
spanObj.value = serializeInfo.toJson.apply(span.value);
}
spans.push(spanObj);
}
});
return {string: this.string_, spans};
}
/**
* Creates a spannable from a json serializable representation.
* @param obj object containing the serializable representation.
*/
static fromJson(obj: SerializedSpannable): Spannable {
if (typeof obj.string !== 'string') {
throw new Error(
'Invalid spannable json object: string field not a string');
}
if (!(obj.spans instanceof Array)) {
throw new Error('Invalid spannable json object: no spans array');
}
const result = new Spannable(obj.string);
result.spans_ = obj.spans.map(span => {
if (typeof span.type !== 'string') {
throw new Error(
'Invalid span in spannable json object: type not a string');
}
if (typeof span.start !== 'number' || typeof span.end !== 'number') {
throw new Error(
'Invalid span in spannable json object: start or end not a number');
}
// TODO(b/314203187): Not null asserted, check that this is correct.
const serializeInfo = serializableSpansByName.get(span.type)!;
const value = serializeInfo.fromJson(span.value);
return {value, start: span.start, end: span.end};
});
return result;
}
/**
* Registers a type that can be converted to a json serializable format.
* @param constructor The type of object that can be converted.
* @param name String identifier used in the serializable format.
* @param fromJson A function that converts the serializable object to an
* actual object of this type.
* @param toJson A function that converts this object to a json serializable
* object. The function will be called with |this| set to the object to
* convert.
*/
static registerSerializableSpan(
constructor: Function, name: string,
fromJson: (json: SerializedSpan) => Annotation,
toJson: () => SerializedSpan): void {
const obj: SerializeInfo = {name, fromJson, toJson};
serializableSpansByName.set(name, obj);
serializableSpansByConstructor.set(constructor, obj);
}
/**
* Registers an object type that can be converted to/from a json
* serializable form. Objects of this type carry no state that will be
* preserved when serialized.
* @param constructor The type of the object that can be converted. This
* constructor will be called with no arguments to construct new objects.
* @param name Name of the type used in the serializable object.
*/
static registerStatelessSerializableSpan(
constructor: Function, name: string): void {
const fromJson = function(_obj: SerializedSpan): Annotation {
return new (constructor as FunctionConstructor)();
};
const obj: SerializeInfo = {name, toJson: undefined, fromJson};
serializableSpansByName.set(name, obj);
serializableSpansByConstructor.set(constructor, obj);
}
}
/**
* A spannable that allows a span value to annotate discontinuous regions of the
* string. In effect, a span value can be set multiple times.
* Note that most methods that assume a span value is unique such as
* |getSpanStart| will use the first span value.
*/
export class MultiSpannable extends Spannable {
/**
* @param string Initial value of the spannable.
* @param annotation Initial annotation for the entire string.
*/
constructor(string?: string | Spannable, annotation?: Annotation) {
super(string, annotation);
}
override setSpan(value: Annotation, start: number, end: number): void {
this.setSpanInternal(value, start, end);
}
override substring(start: number, end: number): MultiSpannable {
const ret = Spannable.prototype.substring.call(this, start, end);
return new MultiSpannable(ret);
}
}
// Local to module.
interface Interval {
start: number;
end: number;
}
/** An annotation with its start and end points. */
interface SpanStruct {
value: Annotation;
start: number;
end: number;
}
/** Describes how to convert a span type to/from serializable json. */
interface SerializeInfo {
name: string;
fromJson: (json: SerializedSpan) => Annotation;
toJson?: () => SerializedSpan;
}
/** The format of a single annotation in a serialized spannable. */
interface SerializedSpan {
type: string;
value: Annotation;
start: number;
end: number;
}
type SpanPredicate = (span: SpanStruct) => boolean;
/** Maps type names to serialization info objects. */
const serializableSpansByName: Map<string, SerializeInfo> = new Map();
/** Maps constructors to serialization info objects. */
const serializableSpansByConstructor: Map<Function, SerializeInfo> = new Map();
// Helpers for implementing the various |get*| methods of |Spannable|.
function spanInstanceOf(constructor: Function): SpanPredicate {
return function(span) {
return span.value instanceof constructor;
};
}
function spanCoversPosition(position: number): SpanPredicate {
return function(span) {
return span.start <= position && position < span.end;
};
}
function spanValueIs(value: Annotation): SpanPredicate {
return function(span) {
return span.value === value;
};
}
function valueOfSpan(span?: SpanStruct): Annotation {
return span ? span.value : undefined;
}
TestImportManager.exportForTesting(Spannable, MultiSpannable);