@idlebox/ignore-edit
Version:
api for modify .*ignore files
282 lines (233 loc) • 6.34 kB
text/typescript
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { arrayUniqueReference } from '@idlebox/common';
export const unscoped = Symbol('unscoped');
const filePath = Symbol('file-path');
const originalContent = Symbol('original-content');
const EMPTYLINE = '@idebox/ignore/space';
export function emptyLine() {
const s = Symbol(EMPTYLINE);
return s;
}
type ILine = symbol | string;
export interface IIgnoreFile extends IIgnoreFileData, IIterable {}
interface IIgnoreFileData {
[unscoped]: ILine[];
[filePath]?: string;
[originalContent]: string;
[title: string]: ILine[];
}
interface IIterable {
[Symbol.iterator](): IterableIterator<[string | typeof unscoped, readonly string[]]>;
}
export function stringify(data: IIgnoreFile): string {
let ret = '';
// console.log('stringify:', data);
for (const [item, arr] of data) {
if (!arr || arr.length === 0) continue;
if (item !== unscoped) {
ret += `\n### ${item}\n`;
}
ret += `${arr
.map((e) => {
return isEmptyLine(e) ? '' : e;
})
.join('\n')}\n`;
}
return `${ret.trim()}\n`;
}
/** @deprecated */
export function saveFileSync(data: IIgnoreFile, saveAs: string = data[filePath]!) {
if (!saveAs) {
throw new Error('not opened by loadFile(), use saveAs');
}
const result = stringify(data);
if (result !== data[originalContent]) {
writeFileSync(saveAs, result, 'utf-8');
return true;
}
return false;
}
export async function saveFile(data: IIgnoreFile, saveAs: string = data[filePath]!) {
if (!saveAs) {
throw new Error('not opened by loadFile(), use saveAs');
}
const result = stringify(data);
if (result !== data[originalContent]) {
await writeFile(saveAs, result, 'utf-8');
return true;
}
return false;
}
function isEmptyLine(line: string | symbol) {
if (typeof line === 'symbol') {
if (line.description === EMPTYLINE) {
return true;
}
}
return false;
}
function trimLastEmptyLines(lines: (string | symbol)[]) {
while (isEmptyLine(lines.at(-1)!)) {
lines.pop();
}
}
function wrapProxy(instance: IIgnoreFileData, content: string): IIgnoreFile {
const lines = content.split('\n').map((l) => l.trim());
const sections: string[] = [];
let current = instance[unscoped];
for (const line of lines) {
if (line.startsWith('###')) {
trimLastEmptyLines(current);
current = new WrappedArray();
const section = line.slice(3).trim();
sections.push(section);
instance[section] = current;
} else if (!line) {
if (current.length > 0 && !isEmptyLine(current.at(-1)!)) {
current.push(emptyLine());
}
} else {
current.push(line);
}
}
return new Proxy(instance as any, {
get(_target, name: string | symbol) {
if (typeof name === 'symbol') {
if (name === Symbol.iterator) {
const list = [unscoped, ...sections];
return function* () {
for (const i of list) yield [i, instance[i as any]];
};
}
return (instance as any)[name];
}
if (!instance[name]) {
instance[name] = new WrappedArray();
sections.push(name);
}
return instance[name];
},
set(_target, name: string, value: string[]) {
if (!Array.isArray(value)) {
throw new TypeError('invalid value (must be array)');
}
if (!(value instanceof WrappedArray)) value = new WrappedArray(...value);
if (typeof name === 'symbol') {
if (unscoped === name) {
instance[unscoped] = value;
return true;
}
throw new Error('do not support symbol index (except "unscoped")');
}
instance[unscoped] = value;
return true;
},
});
}
export function parse(content: string): IIgnoreFile {
const struct: IIgnoreFileData = {
[unscoped]: new WrappedArray(),
[originalContent]: content,
};
return wrapProxy(struct, content);
}
export async function loadFile(file: string, create = false): Promise<IIgnoreFile> {
file = resolve(process.cwd(), file);
if (create && !existsSync(file)) {
await writeFile(file, '');
}
const content = await readFile(file, 'utf-8');
const struct: IIgnoreFileData = {
[unscoped]: new WrappedArray(),
[originalContent]: content,
[filePath]: file,
};
return wrapProxy(struct, content);
}
/** @deprecated */
export function loadFileSync(file: string, create = false): IIgnoreFile {
file = resolve(process.cwd(), file);
if (create && !existsSync(file)) {
writeFileSync(file, '');
}
const content = readFileSync(file, 'utf-8');
const struct: IIgnoreFileData = {
[unscoped]: new WrappedArray(),
[originalContent]: content,
[filePath]: file,
};
return wrapProxy(struct, content);
}
export class WrappedArray extends Array<string> {
override push(...items: string[]): number {
if (items.length === 0) {
return super.length;
}
arrayUniqueReference(items);
let lastIndex = -1;
for (const item of items) {
const index = super.indexOf(item);
if (index >= 0) {
if (index < lastIndex) {
super.splice(index, 1);
super.splice(lastIndex + 1, 0, item);
} else {
lastIndex = index;
}
} else if (lastIndex === -1) {
lastIndex = super.push(item) - 1;
} else {
lastIndex++;
super.splice(lastIndex, 0, item);
}
}
return super.length;
}
override unshift(...items: string[]): number {
if (items.length === 0) {
return super.length;
}
arrayUniqueReference(items);
let lastIndex = 0;
for (const item of items) {
const index = super.indexOf(item);
if (index >= 0) {
if (index < lastIndex) {
super.splice(index, 1);
super.splice(lastIndex + 1, 0, item);
} else {
lastIndex = index;
}
} else {
lastIndex++;
super.splice(lastIndex, 0, item);
}
}
return super.length;
}
override splice(start: number, deleteCount: number, ...items: string[]): string[] {
const ret = super.splice(start, deleteCount);
if (items.length === 0) {
return ret;
}
arrayUniqueReference(items);
let lastIndex = start;
for (const item of items) {
const index = super.indexOf(item);
if (index >= 0) {
if (index < lastIndex) {
super.splice(index, 1);
super.splice(lastIndex + 1, 0, item);
} else {
lastIndex = index;
}
} else {
lastIndex++;
super.splice(lastIndex, 0, item);
}
}
return ret;
}
}