ascii-silhouettify
Version:
A command-line app that converts images into ASCII silhouettes, a style of ASCII art distinguished by uniformly filled geometric shapes rather than lines or textures.
1,429 lines (1,371 loc) • 302 kB
JavaScript
#!/usr/bin/env node
import path$1, { dirname, basename } from 'path';
import { fileURLToPath } from 'url';
import os from 'os';
import { Worker } from 'worker_threads';
import chroma from 'chroma-js';
import sharp from 'sharp';
import { fileURLToPath as fileURLToPath$1 } from 'node:url';
import { win32, posix } from 'node:path';
import { realpathSync as realpathSync$1, lstatSync, readdir, readdirSync, readlinkSync, promises } from 'fs';
import * as actualFS from 'node:fs';
import { lstat, readdir as readdir$1, readlink, realpath } from 'node:fs/promises';
import { EventEmitter } from 'node:events';
import Stream from 'node:stream';
import { StringDecoder } from 'node:string_decoder';
const RGBS = 'DAwMxQ8fE6EOwZwAADfaiBeYOpbdzMzMdnZ250hWFsYM+fGlO3j/tACeYdbW8vLyAAAAAABfAACHAACvAADXAAD/AF8AAF9fAF+HAF+' +
'vAF/XAF//AIcAAIdfAIeHAIevAIfXAIf/AK8AAK9fAK+HAK+vAK/XAK//ANcAANdfANeHANevANfXANf/AP8AAP9fAP+HAP+vAP/XAP//XwAAXwB' +
'fXwCHXwCvXwDXXwD/X18AX19fX1+HX1+vX1/XX1//X4cAX4dfX4eHX4evX4fXX4f/X68AX69fX6+HX6+vX6/XX6//X9cAX9dfX9eHX9evX9fXX9f' +
'/X/8AX/9fX/+HX/+vX//XX///hwAAhwBfhwCHhwCvhwDXhwD/h18Ah19fh1+Hh1+vh1/Xh1//h4cAh4dfh4eHh4evh4fXh4f/h68Ah69fh6+Hh6+' +
'vh6/Xh6//h9cAh9dfh9eHh9evh9fXh9f/h/8Ah/9fh/+Hh/+vh//Xh///rwAArwBfrwCHrwCvrwDXrwD/r18Ar19fr1+Hr1+vr1/Xr1//r4cAr4d' +
'fr4eHr4evr4fXr4f/r68Ar69fr6+Hr6+vr6/Xr6//r9cAr9dfr9eHr9evr9fXr9f/r/8Ar/9fr/+Hr/+vr//Xr///1wAA1wBf1wCH1wCv1wDX1wD' +
'/118A119f11+H11+v11/X11//14cA14df14eH14ev14fX14f/168A169f16+H16+v16/X16//19cA19df19eH19ev19fX19f/1/8A1/9f1/+H1/+' +
'v1//X1////wAA/wBf/wCH/wCv/wDX/wD//18A/19f/1+H/1+v/1/X/1///4cA/4df/4eH/4ev/4fX/4f//68A/69f/6+H/6+v/6/X/6///9cA/9d' +
'f/9eH/9ev/9fX/9f///8A//9f//+H//+v///X////CAgIEhISHBwcJiYmMDAwOjo6RERETk5OWFhYYmJibGxsdnZ2gICAioqKlJSUnp6eqKiosrK' +
'yvLy8xsbG0NDQ2tra5OTk7u7u';
const buffer = Buffer.from(RGBS, 'base64');
var Palette;
(function (Palette) {
Palette[Palette["STANDARD_8"] = 0] = "STANDARD_8";
Palette[Palette["STANDARD_16"] = 1] = "STANDARD_16";
Palette[Palette["EXTENDED_240"] = 2] = "EXTENDED_240";
Palette[Palette["EXTENDED_256"] = 3] = "EXTENDED_256";
})(Palette || (Palette = {}));
const palette = new Array(256);
const closestColorCache = new Map();
function loadHtmlColors() {
const htmlColors = new Array(256);
for (let i = 0, j = 0; i < palette.length; ++i) {
const r = buffer[j++];
const g = buffer[j++];
const b = buffer[j++];
palette[i] = chroma(r, g, b);
htmlColors[i] = ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0').toUpperCase();
}
return htmlColors;
}
function clearClosestColorCache() {
closestColorCache.clear();
}
function findClosestColorIndexAmong(indices, darkness, r, g, b, a) {
const key = (r << 24) | (g << 16) | (b << 8) | a;
const value = closestColorCache.get(key);
if (value !== undefined) {
return value;
}
let index = 0;
const c = chroma(r, g, b).lab();
c[0] *= a / 255;
if (c[0] >= darkness) {
const q = chroma.lab(c[0], c[1], c[2]);
let error = Number.MAX_VALUE;
for (let i = indices.length - 1; i >= 0; --i) {
const p = palette[indices[i]];
const e = chroma.deltaE(p, q);
if (e < error) {
error = e;
index = indices[i];
}
}
}
closestColorCache.set(key, index);
return index;
}
function findClosestColorIndex(pal, darkness, r, g, b, a) {
const key = (r << 24) | (g << 16) | (b << 8) | a;
const value = closestColorCache.get(key);
if (value !== undefined) {
return value;
}
let index = 0;
const c = chroma(r, g, b).lab();
c[0] *= a / 255;
if (c[0] >= darkness) {
const q = chroma.lab(c[0], c[1], c[2]);
let error = Number.MAX_VALUE;
let i;
let minIndex;
switch (pal) {
case Palette.STANDARD_8:
i = 7;
minIndex = 0;
break;
case Palette.STANDARD_16:
i = 15;
minIndex = 0;
break;
case Palette.EXTENDED_240:
i = 255;
minIndex = 16; // Do not compare against the standard 16 ANSI colors since they are commonly redefined.
break;
default:
i = 255;
minIndex = 0;
break;
}
for (; i >= minIndex; --i) {
const p = palette[i];
const e = chroma.deltaE(p, q);
if (e < error) {
error = e;
index = i;
}
}
}
closestColorCache.set(key, index);
return index;
}
const PRINTABLE_ASCII = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
const TERM_WIDTH = 9;
const TERM_HEIGHT = 19;
const HTML_WIDTH = 9.363636363636363;
class GlyphImage {
character;
pixels;
htmlEscapedCharacter;
neofetchEscapedCharacter;
count;
constructor(character, pixels) {
this.character = character;
this.pixels = pixels;
this.character = character;
this.pixels = pixels;
this.count = 0;
for (let i = pixels.length - 1; i >= 0; --i) {
const row = pixels[i];
for (let j = row.length - 1; j >= 0; --j) {
if (row[j]) {
++this.count;
}
}
}
switch (character) {
case '&':
this.htmlEscapedCharacter = '&';
break;
case '<':
this.htmlEscapedCharacter = '<';
break;
case '>':
this.htmlEscapedCharacter = '>';
break;
case '"':
this.htmlEscapedCharacter = '"';
break;
case '\'':
this.htmlEscapedCharacter = ''';
break;
default:
this.htmlEscapedCharacter = character;
break;
}
switch (character) {
case '\\':
this.neofetchEscapedCharacter = '\\\\';
break;
case '$':
this.neofetchEscapedCharacter = '\x24';
break;
default:
this.neofetchEscapedCharacter = character;
break;
}
}
}
class Glyph {
character;
htmlEscapedCharacter;
neofetchEscapedCharacter;
count;
constructor(character, htmlEscapedCharacter, neofetchEscapedCharacter, count) {
this.character = character;
this.htmlEscapedCharacter = htmlEscapedCharacter;
this.neofetchEscapedCharacter = neofetchEscapedCharacter;
this.count = count;
}
}
class GlyphInfo {
masks;
glyphs;
minCount;
constructor(masks, glyphs, minCount) {
this.masks = masks;
this.glyphs = glyphs;
this.minCount = minCount;
}
}
async function loadGlyphs() {
const masks = [];
const glyphsImages = new Array(PRINTABLE_ASCII.length);
const { data, info } = await sharp(path$1.join(dirname(fileURLToPath(import.meta.url)), 'assets', 'glyphs.png'))
.raw().toColourspace('b-w').toBuffer({ resolveWithObject: true });
for (let i = PRINTABLE_ASCII.length - 1; i >= 0; --i) {
const glphyPixels = new Array(TERM_HEIGHT);
const I = i * TERM_WIDTH;
for (let j = TERM_HEIGHT - 1; j >= 0; --j) {
const row = glphyPixels[j] = new Array(TERM_WIDTH);
const J = I + j * info.width;
for (let k = row.length - 1; k >= 0; --k) {
row[k] = (data[J + k] !== 0);
}
}
glyphsImages[i] = new GlyphImage(PRINTABLE_ASCII[i], glphyPixels);
}
glyphsImages.sort((a, b) => a.count - b.count);
masks.length = TERM_WIDTH * TERM_HEIGHT;
for (let i = masks.length - 1; i >= 0; --i) {
masks[i] = [0, 0, 0];
}
for (let i = glyphsImages.length - 1; i >= 0; --i) {
const pixels = glyphsImages[i].pixels;
const index = i >> 5;
const mask = 1 << (i & 31);
for (let j = TERM_HEIGHT - 1; j >= 0; --j) {
const row = pixels[j];
const tableOffset = TERM_WIDTH * j;
for (let k = row.length - 1; k >= 0; --k) {
if (!row[k]) {
masks[tableOffset + k][index] |= mask;
}
}
}
}
const glyphs = new Array(glyphsImages.length);
for (let i = glyphsImages.length - 1; i >= 0; --i) {
const glyphImage = glyphsImages[i];
glyphs[i] = new Glyph(glyphImage.character, glyphImage.htmlEscapedCharacter, glyphImage.neofetchEscapedCharacter, glyphImage.count);
}
return new GlyphInfo(masks, glyphs, glyphs[1].count);
}
class Image {
indices;
width;
height;
neofetchIndices;
neofetchStyles;
constructor(indices, width, height, neofetchIndices, neofetchStyles) {
this.indices = indices;
this.width = width;
this.height = height;
this.neofetchIndices = neofetchIndices;
this.neofetchStyles = neofetchStyles;
}
}
async function loadImage(filename, pal, colors, darkness) {
const { data, info } = await sharp(filename).raw().toBuffer({ resolveWithObject: true });
const indices = new Uint8Array(info.width * info.height);
const frequencies = new Array(256).fill(0);
clearClosestColorCache();
switch (info.channels) {
case 1:
for (let i = 0; i < indices.length; ++i) {
++frequencies[indices[i] = findClosestColorIndex(pal, darkness, data[i], data[i], data[i], 0xFF)];
}
break;
case 2:
for (let i = 0, j = 0; i < indices.length; ++i) {
++frequencies[indices[i] = findClosestColorIndex(pal, darkness, data[j], data[j], data[j++], data[j++])];
}
break;
case 3:
for (let i = 0, j = 0; i < indices.length; ++i) {
++frequencies[indices[i] = findClosestColorIndex(pal, darkness, data[j++], data[j++], data[j++], 0xFF)];
}
break;
case 4:
for (let i = 0, j = 0; i < indices.length; ++i) {
++frequencies[indices[i] = findClosestColorIndex(pal, darkness, data[j++], data[j++], data[j++], data[j++])];
}
break;
}
clearClosestColorCache();
const neofetchIndices = [];
const neofetchStyles = new Array(256);
while (true) {
let maxIndex = -1;
let maxFrequency = 0;
for (let i = frequencies.length - 1; i > 0; --i) {
if (frequencies[i] > maxFrequency) {
maxIndex = i;
maxFrequency = frequencies[i];
}
}
if (maxIndex < 0) {
break;
}
frequencies[maxIndex] = 0;
neofetchIndices.push(maxIndex);
if (neofetchIndices.length <= 6) {
neofetchStyles[maxIndex] = `\${c${neofetchIndices.length}}`;
}
}
if (neofetchIndices.length <= colors) {
return new Image(indices, info.width, info.height, neofetchIndices, neofetchStyles);
}
neofetchIndices.length = colors;
switch (info.channels) {
case 1:
for (let i = 0; i < indices.length; ++i) {
indices[i] = findClosestColorIndexAmong(neofetchIndices, darkness, data[i], data[i], data[i], 0xFF);
}
break;
case 2:
for (let i = 0, j = 0; i < indices.length; ++i) {
indices[i] = findClosestColorIndexAmong(neofetchIndices, darkness, data[j], data[j], data[j++], data[j++]);
}
break;
case 3:
for (let i = 0, j = 0; i < indices.length; ++i) {
indices[i] = findClosestColorIndexAmong(neofetchIndices, darkness, data[j++], data[j++], data[j++], 0xFF);
}
break;
case 4:
for (let i = 0, j = 0; i < indices.length; ++i) {
indices[i] = findClosestColorIndexAmong(neofetchIndices, darkness, data[j++], data[j++], data[j++], data[j++]);
}
break;
}
clearClosestColorCache();
return new Image(indices, info.width, info.height, neofetchIndices, neofetchStyles);
}
function partitionArray(data, numberOfPartitions) {
const result = [];
const minPartitionSize = Math.floor(data.length / numberOfPartitions);
const extraCount = data.length % numberOfPartitions;
let startIndex = 0;
for (let i = 0; i < numberOfPartitions; i++) {
const partitionSize = minPartitionSize + (i < extraCount ? 1 : 0);
const partition = data.slice(startIndex, startIndex + partitionSize);
result.push(partition);
startIndex += partitionSize;
}
return result;
}
class Ascii {
text;
matched;
constructor(text, matched) {
this.text = text;
this.matched = matched;
}
}
class Offset {
x;
y;
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class Task {
offsets;
image;
glyphInfo;
glyphScaleX;
glyphScaleY;
rows;
cols;
rowScale;
colScale;
marginX;
marginY;
color;
format;
palette;
htmlColors;
constructor(offsets, image, glyphInfo, glyphScaleX, glyphScaleY, rows, cols, rowScale, colScale, marginX, marginY, color, format, palette, htmlColors) {
this.offsets = offsets;
this.image = image;
this.glyphInfo = glyphInfo;
this.glyphScaleX = glyphScaleX;
this.glyphScaleY = glyphScaleY;
this.rows = rows;
this.cols = cols;
this.rowScale = rowScale;
this.colScale = colScale;
this.marginX = marginX;
this.marginY = marginY;
this.color = color;
this.format = format;
this.palette = palette;
this.htmlColors = htmlColors;
}
}
var Format;
(function (Format) {
Format[Format["TEXT"] = 0] = "TEXT";
Format[Format["HTML"] = 1] = "HTML";
Format[Format["NEOFETCH"] = 2] = "NEOFETCH";
})(Format || (Format = {}));
async function convert(image, glyphInfo, color, imageScale, fontSize, lineHeight, format, palette, htmlColors, workers) {
let scaledGlyphWidth = HTML_WIDTH * fontSize / 12;
let scaledGlyphHeight = lineHeight * fontSize * 96 / 72;
if (format !== Format.HTML) {
scaledGlyphWidth = Math.round(scaledGlyphWidth);
scaledGlyphHeight = Math.round(scaledGlyphHeight);
}
const glyphScaleX = scaledGlyphWidth / (imageScale * TERM_WIDTH);
const glyphScaleY = scaledGlyphHeight / (imageScale * TERM_HEIGHT);
const scaledImageWidth = imageScale * image.width;
const scaledImageHeight = imageScale * image.height;
const rows = Math.ceil(scaledImageHeight / scaledGlyphHeight);
const cols = Math.ceil(scaledImageWidth / scaledGlyphWidth);
const paddedWidth = Math.ceil(cols * scaledGlyphWidth);
const paddedHeight = Math.ceil(rows * scaledGlyphHeight);
const marginX = (scaledImageWidth - paddedWidth) / 2;
const marginY = (scaledImageHeight - paddedHeight) / 2;
const rowScale = scaledGlyphHeight / imageScale;
const colScale = scaledGlyphWidth / imageScale;
// Repeat the image conversion for various origins within a glyph-sized region and return the best one found.
const offsets = [];
for (let y = -19; y <= 0; ++y) {
for (let x = -9; x <= 0; ++x) {
offsets.push(new Offset(x, y));
}
}
const offs = partitionArray(offsets, workers.length);
let ascii = new Ascii('', -1);
let wc = workers.length;
await new Promise(resolve => {
for (let i = workers.length - 1; i >= 0; --i) {
const worker = workers[i];
function messageHandler(result) {
worker.removeListener('message', messageHandler);
if (result.matched > ascii.matched) {
ascii = result;
}
if (--wc === 0) {
resolve();
}
}
worker.addListener('message', messageHandler);
worker.postMessage(new Task(offs[i], image, glyphInfo, glyphScaleX, glyphScaleY, rows, cols, rowScale, colScale, marginX, marginY, color, format, palette, htmlColors));
}
});
return ascii;
}
function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}
var balancedMatch = balanced$1;
function balanced$1(a, b, str) {
if (a instanceof RegExp) a = maybeMatch(a, str);
if (b instanceof RegExp) b = maybeMatch(b, str);
var r = range(a, b, str);
return r && {
start: r[0],
end: r[1],
pre: str.slice(0, r[0]),
body: str.slice(r[0] + a.length, r[1]),
post: str.slice(r[1] + b.length)
};
}
function maybeMatch(reg, str) {
var m = str.match(reg);
return m ? m[0] : null;
}
balanced$1.range = range;
function range(a, b, str) {
var begs, beg, left, right, result;
var ai = str.indexOf(a);
var bi = str.indexOf(b, ai + 1);
var i = ai;
if (ai >= 0 && bi > 0) {
if(a===b) {
return [ai, bi];
}
begs = [];
left = str.length;
while (i >= 0 && !result) {
if (i == ai) {
begs.push(i);
ai = str.indexOf(a, i + 1);
} else if (begs.length == 1) {
result = [ begs.pop(), bi ];
} else {
beg = begs.pop();
if (beg < left) {
left = beg;
right = bi;
}
bi = str.indexOf(b, i + 1);
}
i = ai < bi && ai >= 0 ? ai : bi;
}
if (begs.length) {
result = [ left, right ];
}
}
return result;
}
var balanced = balancedMatch;
var braceExpansion = expandTop;
var escSlash = '\0SLASH'+Math.random()+'\0';
var escOpen = '\0OPEN'+Math.random()+'\0';
var escClose = '\0CLOSE'+Math.random()+'\0';
var escComma = '\0COMMA'+Math.random()+'\0';
var escPeriod = '\0PERIOD'+Math.random()+'\0';
function numeric(str) {
return parseInt(str, 10) == str
? parseInt(str, 10)
: str.charCodeAt(0);
}
function escapeBraces(str) {
return str.split('\\\\').join(escSlash)
.split('\\{').join(escOpen)
.split('\\}').join(escClose)
.split('\\,').join(escComma)
.split('\\.').join(escPeriod);
}
function unescapeBraces(str) {
return str.split(escSlash).join('\\')
.split(escOpen).join('{')
.split(escClose).join('}')
.split(escComma).join(',')
.split(escPeriod).join('.');
}
// Basically just str.split(","), but handling cases
// where we have nested braced sections, which should be
// treated as individual members, like {a,{b,c},d}
function parseCommaParts(str) {
if (!str)
return [''];
var parts = [];
var m = balanced('{', '}', str);
if (!m)
return str.split(',');
var pre = m.pre;
var body = m.body;
var post = m.post;
var p = pre.split(',');
p[p.length-1] += '{' + body + '}';
var postParts = parseCommaParts(post);
if (post.length) {
p[p.length-1] += postParts.shift();
p.push.apply(p, postParts);
}
parts.push.apply(parts, p);
return parts;
}
function expandTop(str) {
if (!str)
return [];
// I don't know why Bash 4.3 does this, but it does.
// Anything starting with {} will have the first two bytes preserved
// but *only* at the top level, so {},a}b will not expand to anything,
// but a{},b}c will be expanded to [a}c,abc].
// One could argue that this is a bug in Bash, but since the goal of
// this module is to match Bash's rules, we escape a leading {}
if (str.substr(0, 2) === '{}') {
str = '\\{\\}' + str.substr(2);
}
return expand(escapeBraces(str), true).map(unescapeBraces);
}
function embrace(str) {
return '{' + str + '}';
}
function isPadded(el) {
return /^-?0\d/.test(el);
}
function lte(i, y) {
return i <= y;
}
function gte(i, y) {
return i >= y;
}
function expand(str, isTop) {
var expansions = [];
var m = balanced('{', '}', str);
if (!m) return [str];
// no need to expand pre, since it is guaranteed to be free of brace-sets
var pre = m.pre;
var post = m.post.length
? expand(m.post, false)
: [''];
if (/\$$/.test(m.pre)) {
for (var k = 0; k < post.length; k++) {
var expansion = pre+ '{' + m.body + '}' + post[k];
expansions.push(expansion);
}
} else {
var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
var isSequence = isNumericSequence || isAlphaSequence;
var isOptions = m.body.indexOf(',') >= 0;
if (!isSequence && !isOptions) {
// {a},b}
if (m.post.match(/,.*\}/)) {
str = m.pre + '{' + m.body + escClose + m.post;
return expand(str);
}
return [str];
}
var n;
if (isSequence) {
n = m.body.split(/\.\./);
} else {
n = parseCommaParts(m.body);
if (n.length === 1) {
// x{{a,b}}y ==> x{a}y x{b}y
n = expand(n[0], false).map(embrace);
if (n.length === 1) {
return post.map(function(p) {
return m.pre + n[0] + p;
});
}
}
}
// at this point, n is the parts, and we know it's not a comma set
// with a single entry.
var N;
if (isSequence) {
var x = numeric(n[0]);
var y = numeric(n[1]);
var width = Math.max(n[0].length, n[1].length);
var incr = n.length == 3
? Math.abs(numeric(n[2]))
: 1;
var test = lte;
var reverse = y < x;
if (reverse) {
incr *= -1;
test = gte;
}
var pad = n.some(isPadded);
N = [];
for (var i = x; test(i, y); i += incr) {
var c;
if (isAlphaSequence) {
c = String.fromCharCode(i);
if (c === '\\')
c = '';
} else {
c = String(i);
if (pad) {
var need = width - c.length;
if (need > 0) {
var z = new Array(need + 1).join('0');
if (i < 0)
c = '-' + z + c.slice(1);
else
c = z + c;
}
}
}
N.push(c);
}
} else {
N = [];
for (var j = 0; j < n.length; j++) {
N.push.apply(N, expand(n[j], false));
}
}
for (var j = 0; j < N.length; j++) {
for (var k = 0; k < post.length; k++) {
var expansion = pre + N[j] + post[k];
if (!isTop || isSequence || expansion)
expansions.push(expansion);
}
}
}
return expansions;
}
var expand$1 = /*@__PURE__*/getDefaultExportFromCjs(braceExpansion);
const MAX_PATTERN_LENGTH = 1024 * 64;
const assertValidPattern = (pattern) => {
if (typeof pattern !== 'string') {
throw new TypeError('invalid pattern');
}
if (pattern.length > MAX_PATTERN_LENGTH) {
throw new TypeError('pattern is too long');
}
};
// translate the various posix character classes into unicode properties
// this works across all unicode locales
// { <posix class>: [<translation>, /u flag required, negated]
const posixClasses = {
'[:alnum:]': ['\\p{L}\\p{Nl}\\p{Nd}', true],
'[:alpha:]': ['\\p{L}\\p{Nl}', true],
'[:ascii:]': ['\\x' + '00-\\x' + '7f', false],
'[:blank:]': ['\\p{Zs}\\t', true],
'[:cntrl:]': ['\\p{Cc}', true],
'[:digit:]': ['\\p{Nd}', true],
'[:graph:]': ['\\p{Z}\\p{C}', true, true],
'[:lower:]': ['\\p{Ll}', true],
'[:print:]': ['\\p{C}', true],
'[:punct:]': ['\\p{P}', true],
'[:space:]': ['\\p{Z}\\t\\r\\n\\v\\f', true],
'[:upper:]': ['\\p{Lu}', true],
'[:word:]': ['\\p{L}\\p{Nl}\\p{Nd}\\p{Pc}', true],
'[:xdigit:]': ['A-Fa-f0-9', false],
};
// only need to escape a few things inside of brace expressions
// escapes: [ \ ] -
const braceEscape = (s) => s.replace(/[[\]\\-]/g, '\\$&');
// escape all regexp magic characters
const regexpEscape = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
// everything has already been escaped, we just have to join
const rangesToString = (ranges) => ranges.join('');
// takes a glob string at a posix brace expression, and returns
// an equivalent regular expression source, and boolean indicating
// whether the /u flag needs to be applied, and the number of chars
// consumed to parse the character class.
// This also removes out of order ranges, and returns ($.) if the
// entire class just no good.
const parseClass = (glob, position) => {
const pos = position;
/* c8 ignore start */
if (glob.charAt(pos) !== '[') {
throw new Error('not in a brace expression');
}
/* c8 ignore stop */
const ranges = [];
const negs = [];
let i = pos + 1;
let sawStart = false;
let uflag = false;
let escaping = false;
let negate = false;
let endPos = pos;
let rangeStart = '';
WHILE: while (i < glob.length) {
const c = glob.charAt(i);
if ((c === '!' || c === '^') && i === pos + 1) {
negate = true;
i++;
continue;
}
if (c === ']' && sawStart && !escaping) {
endPos = i + 1;
break;
}
sawStart = true;
if (c === '\\') {
if (!escaping) {
escaping = true;
i++;
continue;
}
// escaped \ char, fall through and treat like normal char
}
if (c === '[' && !escaping) {
// either a posix class, a collation equivalent, or just a [
for (const [cls, [unip, u, neg]] of Object.entries(posixClasses)) {
if (glob.startsWith(cls, i)) {
// invalid, [a-[] is fine, but not [a-[:alpha]]
if (rangeStart) {
return ['$.', false, glob.length - pos, true];
}
i += cls.length;
if (neg)
negs.push(unip);
else
ranges.push(unip);
uflag = uflag || u;
continue WHILE;
}
}
}
// now it's just a normal character, effectively
escaping = false;
if (rangeStart) {
// throw this range away if it's not valid, but others
// can still match.
if (c > rangeStart) {
ranges.push(braceEscape(rangeStart) + '-' + braceEscape(c));
}
else if (c === rangeStart) {
ranges.push(braceEscape(c));
}
rangeStart = '';
i++;
continue;
}
// now might be the start of a range.
// can be either c-d or c-] or c<more...>] or c] at this point
if (glob.startsWith('-]', i + 1)) {
ranges.push(braceEscape(c + '-'));
i += 2;
continue;
}
if (glob.startsWith('-', i + 1)) {
rangeStart = c;
i += 2;
continue;
}
// not the start of a range, just a single character
ranges.push(braceEscape(c));
i++;
}
if (endPos < i) {
// didn't see the end of the class, not a valid class,
// but might still be valid as a literal match.
return ['', false, 0, false];
}
// if we got no ranges and no negates, then we have a range that
// cannot possibly match anything, and that poisons the whole glob
if (!ranges.length && !negs.length) {
return ['$.', false, glob.length - pos, true];
}
// if we got one positive range, and it's a single character, then that's
// not actually a magic pattern, it's just that one literal character.
// we should not treat that as "magic", we should just return the literal
// character. [_] is a perfectly valid way to escape glob magic chars.
if (negs.length === 0 &&
ranges.length === 1 &&
/^\\?.$/.test(ranges[0]) &&
!negate) {
const r = ranges[0].length === 2 ? ranges[0].slice(-1) : ranges[0];
return [regexpEscape(r), false, endPos - pos, false];
}
const sranges = '[' + (negate ? '^' : '') + rangesToString(ranges) + ']';
const snegs = '[' + (negate ? '' : '^') + rangesToString(negs) + ']';
const comb = ranges.length && negs.length
? '(' + sranges + '|' + snegs + ')'
: ranges.length
? sranges
: snegs;
return [comb, uflag, endPos - pos, true];
};
/**
* Un-escape a string that has been escaped with {@link escape}.
*
* If the {@link windowsPathsNoEscape} option is used, then square-brace
* escapes are removed, but not backslash escapes. For example, it will turn
* the string `'[*]'` into `*`, but it will not turn `'\\*'` into `'*'`,
* becuase `\` is a path separator in `windowsPathsNoEscape` mode.
*
* When `windowsPathsNoEscape` is not set, then both brace escapes and
* backslash escapes are removed.
*
* Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot be escaped
* or unescaped.
*/
const unescape = (s, { windowsPathsNoEscape = false, } = {}) => {
return windowsPathsNoEscape
? s.replace(/\[([^\/\\])\]/g, '$1')
: s.replace(/((?!\\).|^)\[([^\/\\])\]/g, '$1$2').replace(/\\([^\/])/g, '$1');
};
// parse a single path portion
const types = new Set(['!', '?', '+', '*', '@']);
const isExtglobType = (c) => types.has(c);
// Patterns that get prepended to bind to the start of either the
// entire string, or just a single path portion, to prevent dots
// and/or traversal patterns, when needed.
// Exts don't need the ^ or / bit, because the root binds that already.
const startNoTraversal = '(?!(?:^|/)\\.\\.?(?:$|/))';
const startNoDot = '(?!\\.)';
// characters that indicate a start of pattern needs the "no dots" bit,
// because a dot *might* be matched. ( is not in the list, because in
// the case of a child extglob, it will handle the prevention itself.
const addPatternStart = new Set(['[', '.']);
// cases where traversal is A-OK, no dot prevention needed
const justDots = new Set(['..', '.']);
const reSpecials = new Set('().*{}+?[]^$\\!');
const regExpEscape$1 = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
// any single thing other than /
const qmark$1 = '[^/]';
// * => any number of characters
const star$1 = qmark$1 + '*?';
// use + when we need to ensure that *something* matches, because the * is
// the only thing in the path portion.
const starNoEmpty = qmark$1 + '+?';
// remove the \ chars that we added if we end up doing a nonmagic compare
// const deslash = (s: string) => s.replace(/\\(.)/g, '$1')
class AST {
type;
#root;
#hasMagic;
#uflag = false;
#parts = [];
#parent;
#parentIndex;
#negs;
#filledNegs = false;
#options;
#toString;
// set to true if it's an extglob with no children
// (which really means one child of '')
#emptyExt = false;
constructor(type, parent, options = {}) {
this.type = type;
// extglobs are inherently magical
if (type)
this.#hasMagic = true;
this.#parent = parent;
this.#root = this.#parent ? this.#parent.#root : this;
this.#options = this.#root === this ? options : this.#root.#options;
this.#negs = this.#root === this ? [] : this.#root.#negs;
if (type === '!' && !this.#root.#filledNegs)
this.#negs.push(this);
this.#parentIndex = this.#parent ? this.#parent.#parts.length : 0;
}
get hasMagic() {
/* c8 ignore start */
if (this.#hasMagic !== undefined)
return this.#hasMagic;
/* c8 ignore stop */
for (const p of this.#parts) {
if (typeof p === 'string')
continue;
if (p.type || p.hasMagic)
return (this.#hasMagic = true);
}
// note: will be undefined until we generate the regexp src and find out
return this.#hasMagic;
}
// reconstructs the pattern
toString() {
if (this.#toString !== undefined)
return this.#toString;
if (!this.type) {
return (this.#toString = this.#parts.map(p => String(p)).join(''));
}
else {
return (this.#toString =
this.type + '(' + this.#parts.map(p => String(p)).join('|') + ')');
}
}
#fillNegs() {
/* c8 ignore start */
if (this !== this.#root)
throw new Error('should only call on root');
if (this.#filledNegs)
return this;
/* c8 ignore stop */
// call toString() once to fill this out
this.toString();
this.#filledNegs = true;
let n;
while ((n = this.#negs.pop())) {
if (n.type !== '!')
continue;
// walk up the tree, appending everthing that comes AFTER parentIndex
let p = n;
let pp = p.#parent;
while (pp) {
for (let i = p.#parentIndex + 1; !pp.type && i < pp.#parts.length; i++) {
for (const part of n.#parts) {
/* c8 ignore start */
if (typeof part === 'string') {
throw new Error('string part in extglob AST??');
}
/* c8 ignore stop */
part.copyIn(pp.#parts[i]);
}
}
p = pp;
pp = p.#parent;
}
}
return this;
}
push(...parts) {
for (const p of parts) {
if (p === '')
continue;
/* c8 ignore start */
if (typeof p !== 'string' && !(p instanceof AST && p.#parent === this)) {
throw new Error('invalid part: ' + p);
}
/* c8 ignore stop */
this.#parts.push(p);
}
}
toJSON() {
const ret = this.type === null
? this.#parts.slice().map(p => (typeof p === 'string' ? p : p.toJSON()))
: [this.type, ...this.#parts.map(p => p.toJSON())];
if (this.isStart() && !this.type)
ret.unshift([]);
if (this.isEnd() &&
(this === this.#root ||
(this.#root.#filledNegs && this.#parent?.type === '!'))) {
ret.push({});
}
return ret;
}
isStart() {
if (this.#root === this)
return true;
// if (this.type) return !!this.#parent?.isStart()
if (!this.#parent?.isStart())
return false;
if (this.#parentIndex === 0)
return true;
// if everything AHEAD of this is a negation, then it's still the "start"
const p = this.#parent;
for (let i = 0; i < this.#parentIndex; i++) {
const pp = p.#parts[i];
if (!(pp instanceof AST && pp.type === '!')) {
return false;
}
}
return true;
}
isEnd() {
if (this.#root === this)
return true;
if (this.#parent?.type === '!')
return true;
if (!this.#parent?.isEnd())
return false;
if (!this.type)
return this.#parent?.isEnd();
// if not root, it'll always have a parent
/* c8 ignore start */
const pl = this.#parent ? this.#parent.#parts.length : 0;
/* c8 ignore stop */
return this.#parentIndex === pl - 1;
}
copyIn(part) {
if (typeof part === 'string')
this.push(part);
else
this.push(part.clone(this));
}
clone(parent) {
const c = new AST(this.type, parent);
for (const p of this.#parts) {
c.copyIn(p);
}
return c;
}
static #parseAST(str, ast, pos, opt) {
let escaping = false;
let inBrace = false;
let braceStart = -1;
let braceNeg = false;
if (ast.type === null) {
// outside of a extglob, append until we find a start
let i = pos;
let acc = '';
while (i < str.length) {
const c = str.charAt(i++);
// still accumulate escapes at this point, but we do ignore
// starts that are escaped
if (escaping || c === '\\') {
escaping = !escaping;
acc += c;
continue;
}
if (inBrace) {
if (i === braceStart + 1) {
if (c === '^' || c === '!') {
braceNeg = true;
}
}
else if (c === ']' && !(i === braceStart + 2 && braceNeg)) {
inBrace = false;
}
acc += c;
continue;
}
else if (c === '[') {
inBrace = true;
braceStart = i;
braceNeg = false;
acc += c;
continue;
}
if (!opt.noext && isExtglobType(c) && str.charAt(i) === '(') {
ast.push(acc);
acc = '';
const ext = new AST(c, ast);
i = AST.#parseAST(str, ext, i, opt);
ast.push(ext);
continue;
}
acc += c;
}
ast.push(acc);
return i;
}
// some kind of extglob, pos is at the (
// find the next | or )
let i = pos + 1;
let part = new AST(null, ast);
const parts = [];
let acc = '';
while (i < str.length) {
const c = str.charAt(i++);
// still accumulate escapes at this point, but we do ignore
// starts that are escaped
if (escaping || c === '\\') {
escaping = !escaping;
acc += c;
continue;
}
if (inBrace) {
if (i === braceStart + 1) {
if (c === '^' || c === '!') {
braceNeg = true;
}
}
else if (c === ']' && !(i === braceStart + 2 && braceNeg)) {
inBrace = false;
}
acc += c;
continue;
}
else if (c === '[') {
inBrace = true;
braceStart = i;
braceNeg = false;
acc += c;
continue;
}
if (isExtglobType(c) && str.charAt(i) === '(') {
part.push(acc);
acc = '';
const ext = new AST(c, part);
part.push(ext);
i = AST.#parseAST(str, ext, i, opt);
continue;
}
if (c === '|') {
part.push(acc);
acc = '';
parts.push(part);
part = new AST(null, ast);
continue;
}
if (c === ')') {
if (acc === '' && ast.#parts.length === 0) {
ast.#emptyExt = true;
}
part.push(acc);
acc = '';
ast.push(...parts, part);
return i;
}
acc += c;
}
// unfinished extglob
// if we got here, it was a malformed extglob! not an extglob, but
// maybe something else in there.
ast.type = null;
ast.#hasMagic = undefined;
ast.#parts = [str.substring(pos - 1)];
return i;
}
static fromGlob(pattern, options = {}) {
const ast = new AST(null, undefined, options);
AST.#parseAST(pattern, ast, 0, options);
return ast;
}
// returns the regular expression if there's magic, or the unescaped
// string if not.
toMMPattern() {
// should only be called on root
/* c8 ignore start */
if (this !== this.#root)
return this.#root.toMMPattern();
/* c8 ignore stop */
const glob = this.toString();
const [re, body, hasMagic, uflag] = this.toRegExpSource();
// if we're in nocase mode, and not nocaseMagicOnly, then we do
// still need a regular expression if we have to case-insensitively
// match capital/lowercase characters.
const anyMagic = hasMagic ||
this.#hasMagic ||
(this.#options.nocase &&
!this.#options.nocaseMagicOnly &&
glob.toUpperCase() !== glob.toLowerCase());
if (!anyMagic) {
return body;
}
const flags = (this.#options.nocase ? 'i' : '') + (uflag ? 'u' : '');
return Object.assign(new RegExp(`^${re}$`, flags), {
_src: re,
_glob: glob,
});
}
get options() {
return this.#options;
}
// returns the string match, the regexp source, whether there's magic
// in the regexp (so a regular expression is required) and whether or
// not the uflag is needed for the regular expression (for posix classes)
// TODO: instead of injecting the start/end at this point, just return
// the BODY of the regexp, along with the start/end portions suitable
// for binding the start/end in either a joined full-path makeRe context
// (where we bind to (^|/), or a standalone matchPart context (where
// we bind to ^, and not /). Otherwise slashes get duped!
//
// In part-matching mode, the start is:
// - if not isStart: nothing
// - if traversal possible, but not allowed: ^(?!\.\.?$)
// - if dots allowed or not possible: ^
// - if dots possible and not allowed: ^(?!\.)
// end is:
// - if not isEnd(): nothing
// - else: $
//
// In full-path matching mode, we put the slash at the START of the
// pattern, so start is:
// - if first pattern: same as part-matching mode
// - if not isStart(): nothing
// - if traversal possible, but not allowed: /(?!\.\.?(?:$|/))
// - if dots allowed or not possible: /
// - if dots possible and not allowed: /(?!\.)
// end is:
// - if last pattern, same as part-matching mode
// - else nothing
//
// Always put the (?:$|/) on negated tails, though, because that has to be
// there to bind the end of the negated pattern portion, and it's easier to
// just stick it in now rather than try to inject it later in the middle of
// the pattern.
//
// We can just always return the same end, and leave it up to the caller
// to know whether it's going to be used joined or in parts.
// And, if the start is adjusted slightly, can do the same there:
// - if not isStart: nothing
// - if traversal possible, but not allowed: (?:/|^)(?!\.\.?$)
// - if dots allowed or not possible: (?:/|^)
// - if dots possible and not allowed: (?:/|^)(?!\.)
//
// But it's better to have a simpler binding without a conditional, for
// performance, so probably better to return both start options.
//
// Then the caller just ignores the end if it's not the first pattern,
// and the start always gets applied.
//
// But that's always going to be $ if it's the ending pattern, or nothing,
// so the caller can just attach $ at the end of the pattern when building.
//
// So the todo is:
// - better detect what kind of start is needed
// - return both flavors of starting pattern
// - attach $ at the end of the pattern when creating the actual RegExp
//
// Ah, but wait, no, that all only applies to the root when the first pattern
// is not an extglob. If the first pattern IS an extglob, then we need all
// that dot prevention biz to live in the extglob portions, because eg
// +(*|.x*) can match .xy but not .yx.
//
// So, return the two flavors if it's #root and the first child is not an
// AST, otherwise leave it to the child AST to handle it, and there,
// use the (?:^|/) style of start binding.
//
// Even simplified further:
// - Since the start for a join is eg /(?!\.) and the start for a part
// is ^(?!\.), we can just prepend (?!\.) to the pattern (either root
// or start or whatever) and prepend ^ or / at the Regexp construction.
toRegExpSource(allowDot) {
const dot = allowDot ?? !!this.#options.dot;
if (this.#root === this)
this.#fillNegs();
if (!this.type) {
const noEmpty = this.isStart() && this.isEnd();
const src = this.#parts
.map(p => {
const [re, _, hasMagic, uflag] = typeof p === 'string'
? AST.#parseGlob(p, this.#hasMagic, noEmpty)
: p.toRegExpSource(allowDot);
this.#hasMagic = this.#hasMagic || hasMagic;
this.#uflag = this.#uflag || uflag;
return re;
})
.join('');
let start = '';
if (this.isStart()) {
if (typeof this.#parts[0] === 'string') {
// this is the string that will match the start of the pattern,
// so we need to protect against dots and such.
// '.' and '..' cannot match unless the pattern is that exactly,
// even if it starts with . or dot:true is set.
const dotTravAllowed = this.#parts.length === 1 && justDots.has(this.#parts[0]);
if (!dotTravAllowed) {
const aps = addPatternStart;
// check if we have a possibility of matching . or ..,
// and prevent that.
const needNoTrav =
// dots are allowed, and the pattern starts with [ or .
(dot && aps.has(src.charAt(0))) ||
// the pattern starts with \., and then [ or .
(src.startsWith('\\.') && aps.has(src.charAt(2))) ||
// the pattern starts with \.\., and then [ or .
(src.startsWith('\\.\\.') && aps.has(src.charAt(4)));
// no need to prevent dots if it can't match a dot, or if a
// sub-pattern will be preventing it anyway.
const needNoDot = !dot && !allowDot && aps.has(src.charAt(0));
start = needNoTrav ? startNoTraversal : needNoDot ? startNoDot : '';
}
}
}
// append the "end of path portion" pattern to negation tails
let end = '';
if (this.isEnd() &&
this.#root.#filledNegs &&
this.#parent?.type === '!') {
end = '(?:$|\\/)';
}
const final = start + src + end;
return [
final,
unescape(src),
(this.#hasMagic = !!this.#hasMagic),
this.#uflag,
];
}
// We need to calculate the body *twice* if it's a repeat pattern
// at the start, once in nodot mode, then again in dot mode, so a
// pattern like *(?) can match 'x.y'
const repeated = this.type === '*' || this.type === '+';
// some kind of extglob
const start = this.type === '!' ? '(?:(?!(?:' : '(?:';
let body = this.#partsToRegExp(dot);
if (this.isStart() && this.isEnd() && !body && this.type !== '!') {
// invalid extglob, has to at least be *something* present, if it's
// the entire path portion.
const s = this.toString();
this.#parts = [s];
this.type = null;
this.#hasMagic = undefined;
return [s, unescape(this.toString()), false, false];
}
// XXX abstract out this map method
let bodyDotAllowed = !repeated || allowDot || dot || false
? ''
: this.#partsToRegExp(true);
if (bodyDotAllowed === body) {
bodyDotAllowed = '';
}
if (bodyDotAllowed) {
body = `(?:${body})(?:${bodyDotAllowed})*?`;
}
// an empty !() is exactly equivalent to a starNoEmpty
let final = '';
if (this.type === '!' && this.#emptyExt) {
final = (this.isStart() && !dot ? startNoDot : '') + starNoEmpty;
}
else {
const close = this.type === '!'
? // !() must match something,but !(x) can match ''
'))' +
(this.isStart() && !dot && !allowDot ? startNoDot : '') +
star$1 +
')'
: this.type === '@'
? ')'
: this.type === '?'
? ')?'
: this.type === '+' && bodyDotAllowed
? ')'
: this.type === '*' && bodyDotAllowed
? `)?`
: `)${this.type}`;
final = start + body + close;
}
return [
final,
unescape(body),
(this.#hasMagic = !!this.#hasMagic),
this.#uflag,
];
}
#partsToRegExp(dot) {
return this.#parts
.map(p => {