Files
blockly/core/utils/string.ts
Christopher Allen b0a7c004a9 refactor(build): Delete Closure Library (#7415)
* fix(build): Restore erroneously-deleted filter function

  This was deleted in PR #7406 as it was mainly being used to
  filter core/ vs. test/mocha/ deps into separate deps files -
  but it turns out also to be used for filtering error
  messages too.  Oops.

* refactor(tests): Migrate advanced compilation test to ES Modules

* refactor(build): Migrate main.js to TypeScript

  This turns out to be pretty straight forward, even if it would
  cause crashing if one actually tried to import this module
  instead of just feeding it to Closure Compiler.

* chore(build): Remove goog.declareModuleId calls

  Replace goog.declareModuleId calls with a comment recording the
  former module ID for posterity (or at least until we decide
  how to reformat the renamings file.

* chore(tests): Delete closure/goog/*

  For the moment we still need something to serve as base.js for
  the benefit of closure-make-deps, so we keep a vestigial
  base.js around, containing only the @provideGoog declaration.

* refactor(build): Remove vestigial base.js

  By changing slightly the command line arguments to
  closure-make-deps and closure-calculate-chunks the need to have
  any base.js is eliminated.

* chore: Typo fix for PR #7415
2023-08-31 00:24:47 +01:00

311 lines
8.4 KiB
TypeScript

/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.utils.string
import * as deprecation from './deprecation.js';
/**
* Fast prefix-checker.
* Copied from Closure's goog.string.startsWith.
*
* @param str The string to check.
* @param prefix A string to look for at the start of `str`.
* @returns True if `str` begins with `prefix`.
* @deprecated Use built-in **string.startsWith** instead.
*/
export function startsWith(str: string, prefix: string): boolean {
deprecation.warn(
'Blockly.utils.string.startsWith()',
'April 2022',
'April 2023',
'Use built-in string.startsWith',
);
return str.startsWith(prefix);
}
/**
* Given an array of strings, return the length of the shortest one.
*
* @param array Array of strings.
* @returns Length of shortest string.
*/
export function shortestStringLength(array: string[]): number {
if (!array.length) {
return 0;
}
return array.reduce(function (a, b) {
return a.length < b.length ? a : b;
}).length;
}
/**
* Given an array of strings, return the length of the common prefix.
* Words may not be split. Any space after a word is included in the length.
*
* @param array Array of strings.
* @param opt_shortest Length of shortest string.
* @returns Length of common prefix.
*/
export function commonWordPrefix(
array: string[],
opt_shortest?: number,
): number {
if (!array.length) {
return 0;
} else if (array.length === 1) {
return array[0].length;
}
let wordPrefix = 0;
const max = opt_shortest || shortestStringLength(array);
let len;
for (len = 0; len < max; len++) {
const letter = array[0][len];
for (let i = 1; i < array.length; i++) {
if (letter !== array[i][len]) {
return wordPrefix;
}
}
if (letter === ' ') {
wordPrefix = len + 1;
}
}
for (let i = 1; i < array.length; i++) {
const letter = array[i][len];
if (letter && letter !== ' ') {
return wordPrefix;
}
}
return max;
}
/**
* Given an array of strings, return the length of the common suffix.
* Words may not be split. Any space after a word is included in the length.
*
* @param array Array of strings.
* @param opt_shortest Length of shortest string.
* @returns Length of common suffix.
*/
export function commonWordSuffix(
array: string[],
opt_shortest?: number,
): number {
if (!array.length) {
return 0;
} else if (array.length === 1) {
return array[0].length;
}
let wordPrefix = 0;
const max = opt_shortest || shortestStringLength(array);
let len;
for (len = 0; len < max; len++) {
const letter = array[0].substr(-len - 1, 1);
for (let i = 1; i < array.length; i++) {
if (letter !== array[i].substr(-len - 1, 1)) {
return wordPrefix;
}
}
if (letter === ' ') {
wordPrefix = len + 1;
}
}
for (let i = 1; i < array.length; i++) {
const letter = array[i].charAt(array[i].length - len - 1);
if (letter && letter !== ' ') {
return wordPrefix;
}
}
return max;
}
/**
* Wrap text to the specified width.
*
* @param text Text to wrap.
* @param limit Width to wrap each line.
* @returns Wrapped text.
*/
export function wrap(text: string, limit: number): string {
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
lines[i] = wrapLine(lines[i], limit);
}
return lines.join('\n');
}
/**
* Wrap single line of text to the specified width.
*
* @param text Text to wrap.
* @param limit Width to wrap each line.
* @returns Wrapped text.
*/
function wrapLine(text: string, limit: number): string {
if (text.length <= limit) {
// Short text, no need to wrap.
return text;
}
// Split the text into words.
const words = text.trim().split(/\s+/);
// Set limit to be the length of the largest word.
for (let i = 0; i < words.length; i++) {
if (words[i].length > limit) {
limit = words[i].length;
}
}
let lastScore;
let score = -Infinity;
let lastText;
let lineCount = 1;
do {
lastScore = score;
lastText = text;
// Create a list of booleans representing if a space (false) or
// a break (true) appears after each word.
let wordBreaks = [];
// Seed the list with evenly spaced linebreaks.
const steps = words.length / lineCount;
let insertedBreaks = 1;
for (let i = 0; i < words.length - 1; i++) {
if (insertedBreaks < (i + 1.5) / steps) {
insertedBreaks++;
wordBreaks[i] = true;
} else {
wordBreaks[i] = false;
}
}
wordBreaks = wrapMutate(words, wordBreaks, limit);
score = wrapScore(words, wordBreaks, limit);
text = wrapToText(words, wordBreaks);
lineCount++;
} while (score > lastScore);
return lastText;
}
/**
* Compute a score for how good the wrapping is.
*
* @param words Array of each word.
* @param wordBreaks Array of line breaks.
* @param limit Width to wrap each line.
* @returns Larger the better.
*/
function wrapScore(
words: string[],
wordBreaks: boolean[],
limit: number,
): number {
// If this function becomes a performance liability, add caching.
// Compute the length of each line.
const lineLengths = [0];
const linePunctuation = [];
for (let i = 0; i < words.length; i++) {
lineLengths[lineLengths.length - 1] += words[i].length;
if (wordBreaks[i] === true) {
lineLengths.push(0);
linePunctuation.push(words[i].charAt(words[i].length - 1));
} else if (wordBreaks[i] === false) {
lineLengths[lineLengths.length - 1]++;
}
}
const maxLength = Math.max(...lineLengths);
let score = 0;
for (let i = 0; i < lineLengths.length; i++) {
// Optimize for width.
// -2 points per char over limit (scaled to the power of 1.5).
score -= Math.pow(Math.abs(limit - lineLengths[i]), 1.5) * 2;
// Optimize for even lines.
// -1 point per char smaller than max (scaled to the power of 1.5).
score -= Math.pow(maxLength - lineLengths[i], 1.5);
// Optimize for structure.
// Add score to line endings after punctuation.
if ('.?!'.indexOf(linePunctuation[i]) !== -1) {
score += limit / 3;
} else if (',;)]}'.indexOf(linePunctuation[i]) !== -1) {
score += limit / 4;
}
}
// All else being equal, the last line should not be longer than the
// previous line. For example, this looks wrong:
// aaa bbb
// ccc ddd eee
if (
lineLengths.length > 1 &&
lineLengths[lineLengths.length - 1] <= lineLengths[lineLengths.length - 2]
) {
score += 0.5;
}
return score;
}
/**
* Mutate the array of line break locations until an optimal solution is found.
* No line breaks are added or deleted, they are simply moved around.
*
* @param words Array of each word.
* @param wordBreaks Array of line breaks.
* @param limit Width to wrap each line.
* @returns New array of optimal line breaks.
*/
function wrapMutate(
words: string[],
wordBreaks: boolean[],
limit: number,
): boolean[] {
let bestScore = wrapScore(words, wordBreaks, limit);
let bestBreaks;
// Try shifting every line break forward or backward.
for (let i = 0; i < wordBreaks.length - 1; i++) {
if (wordBreaks[i] === wordBreaks[i + 1]) {
continue;
}
const mutatedWordBreaks = new Array<boolean>().concat(wordBreaks);
mutatedWordBreaks[i] = !mutatedWordBreaks[i];
mutatedWordBreaks[i + 1] = !mutatedWordBreaks[i + 1];
const mutatedScore = wrapScore(words, mutatedWordBreaks, limit);
if (mutatedScore > bestScore) {
bestScore = mutatedScore;
bestBreaks = mutatedWordBreaks;
}
}
if (bestBreaks) {
// Found an improvement. See if it may be improved further.
return wrapMutate(words, bestBreaks, limit);
}
// No improvements found. Done.
return wordBreaks;
}
/**
* Reassemble the array of words into text, with the specified line breaks.
*
* @param words Array of each word.
* @param wordBreaks Array of line breaks.
* @returns Plain text.
*/
function wrapToText(words: string[], wordBreaks: boolean[]): string {
const text = [];
for (let i = 0; i < words.length; i++) {
text.push(words[i]);
if (wordBreaks[i] !== undefined) {
text.push(wordBreaks[i] ? '\n' : ' ');
}
}
return text.join('');
}
/**
* Is the given string a number (includes negative and decimals).
*
* @param str Input string.
* @returns True if number, false otherwise.
*/
export function isNumber(str: string): boolean {
return /^\s*-?\d+(\.\d+)?\s*$/.test(str);
}