@aljazmc/config 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,25 +1,49 @@
1
1
  # > config
2
2
 
3
- Shared, dependency-free configuration loader, file finder and glob matcher, bundled
4
- into every `@aljazmc` package and published for their types.
3
+ Shared, dependency-free configuration loader, file finder and glob
4
+ matcher, bundled into every `@aljazmc` package and published for their
5
+ types. Each package ships its defaults, and `<package>.config.ts` (or
6
+ `.js`) in the current directory overrides them key by key.
5
7
 
6
- Each package ships its defaults in `src/config.ts`. On run, `<package>.config.ts`
7
- (or `.js`) in the current directory, if present, overrides them key by key.
8
+ ## > Features
9
+
10
+ | Export / member | Description |
11
+ | ------------------ | --------------------------------------------------------------- |
12
+ | `config.custom` | What the config file set, before the defaults were merged in. |
13
+ | `config.files()` | Lists the files that are not ignored. |
14
+ | `config.sources()` | Reads the text files that are not ignored. |
15
+ | `Includes` | Matches `includes` globs: the last match decides, `!` excludes. |
16
+ | `Pattern` | Matches a single glob. |
17
+
18
+ ## > Ignore patterns
8
19
 
9
20
  ```js
10
21
  // testero.config.js
11
22
  export default {
12
23
  ignore: [
13
- "node_modules", // name: any file or directory called node_modules
14
- "packages/testero/tests", // path: relative to root, with its contents
15
- "*.log", // globs: * and ? within a segment, ** across segments, {a,b} alternatives
16
- "packages/*/bin",
17
24
  "**/tmp/*.ts",
25
+ "*.log",
26
+ "node_modules",
27
+ "packages/*/bin",
28
+ "packages/testero/tests",
18
29
  ],
19
30
  };
20
31
  ```
21
32
 
22
- `config.files()` lists the files that are not ignored, and `config.sources()`
23
- reads the text ones. `Includes` matches `includes` globs like formatero and lintero use them, where
24
- the last matching glob decides and `!` excludes. `config.custom` holds what the
25
- config file set, before the defaults were merged in.
33
+ | Pattern | Matches |
34
+ | -------- | ------------------------------------------------------- |
35
+ | `**` | Any number of segments. |
36
+ | `*`, `?` | Any characters, or one character, within a segment. |
37
+ | `/name` | `name` at the root only, like `./name`. |
38
+ | `name/` | Folders only, with their contents. |
39
+ | `{a,b}` | Alternatives. |
40
+ | name | Any file or folder with that name, like `node_modules`. |
41
+ | path | A path relative to the root, with its contents. |
42
+
43
+ Symlinks are followed. Binary files are skipped. Broken symlinks,
44
+ unreadable folders and files that are not UTF-8 are skipped with a
45
+ warning.
46
+
47
+ ## > License
48
+
49
+ MIT, see [LICENSE](LICENSE).
package/bin/index.js CHANGED
@@ -2,7 +2,14 @@ var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
4
  // shared/config/src/config.ts
5
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import {
6
+ existsSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ statSync,
11
+ writeFileSync
12
+ } from "node:fs";
6
13
  import { join } from "node:path";
7
14
  import { pathToFileURL } from "node:url";
8
15
 
@@ -12,22 +19,33 @@ var Pattern = class _Pattern {
12
19
  __name(this, "Pattern");
13
20
  }
14
21
  #anchored;
22
+ #directories;
15
23
  #regex;
16
- constructor(source) {
24
+ /** With the name of the setting the glob comes from, for its errors. */
25
+ constructor(source, setting = "glob") {
26
+ this.#directories = /[^/]\/+$/.test(source);
17
27
  const glob = source.replace(/^\.?\/+|\/+$/g, "");
18
- this.#anchored = glob.includes("/");
19
- this.#regex = new RegExp(`^${_Pattern.#toRegex(glob)}$`);
28
+ this.#anchored = glob.includes("/") || /^\.?\//.test(source);
29
+ this.#regex = new RegExp(
30
+ `^${_Pattern.#toRegex(glob, source, setting)}$`
31
+ );
20
32
  }
21
- matches(path) {
33
+ /**
34
+ * Whether a path, or a folder it is in, matches. Whether the path itself
35
+ * is a folder matters to globs ending with `/`.
36
+ */
37
+ matches(path, directory = false) {
22
38
  const segments = path.split("/");
23
- return segments.some(
24
- (segment, index) => this.#regex.test(
39
+ return segments.some((segment, index) => {
40
+ if (this.#directories && index === segments.length - 1 && !directory)
41
+ return false;
42
+ return this.#regex.test(
25
43
  this.#anchored ? segments.slice(0, index + 1).join("/") : segment
26
- )
27
- );
44
+ );
45
+ });
28
46
  }
29
47
  /** Translates `**`, `*`, `?` and `{a,b}` alternatives, other characters match themselves. */
30
- static #toRegex(glob) {
48
+ static #toRegex(glob, source, setting) {
31
49
  const tokens = {
32
50
  "**/": "(?:.*/)?",
33
51
  "**": ".*",
@@ -37,7 +55,7 @@ var Pattern = class _Pattern {
37
55
  let regex = "";
38
56
  let braces = 0;
39
57
  for (const [token] of glob.matchAll(/\*\*\/|\*\*|[\s\S]/g)) {
40
- if (token in tokens) regex += tokens[token] ?? token;
58
+ if (Object.hasOwn(tokens, token)) regex += tokens[token];
41
59
  else if (token === "{") {
42
60
  braces++;
43
61
  regex += "(?:";
@@ -47,6 +65,10 @@ var Pattern = class _Pattern {
47
65
  } else if (token === "," && braces > 0) regex += "|";
48
66
  else regex += token.replace(/[.+^${}()|[\]\\]/g, "\\$&");
49
67
  }
68
+ if (braces > 0)
69
+ throw new Error(
70
+ `Invalid ${setting} ${JSON.stringify(source)}: a { is not closed by a }.`
71
+ );
50
72
  return regex;
51
73
  }
52
74
  };
@@ -58,11 +80,14 @@ var Ignore = class {
58
80
  }
59
81
  #patterns;
60
82
  constructor(sources) {
61
- this.#patterns = sources.map((source) => new Pattern(source));
83
+ this.#patterns = sources.map((source) => new Pattern(source, "ignore"));
62
84
  }
63
- matches(path) {
85
+ /** Whether a path is ignored, globs ending with `/` only ignore folders. */
86
+ matches(path, directory = false) {
64
87
  const normalized = path.replaceAll("\\", "/").replace(/^\.\//, "");
65
- return this.#patterns.some((pattern) => pattern.matches(normalized));
88
+ return this.#patterns.some(
89
+ (pattern) => pattern.matches(normalized, directory)
90
+ );
66
91
  }
67
92
  };
68
93
 
@@ -77,6 +102,11 @@ var Config = class _Config {
77
102
  rules;
78
103
  /** What the config file set, before the defaults were merged in. */
79
104
  custom;
105
+ /**
106
+ * Reports files and folders that are left out, like a broken symlink, an
107
+ * unreadable folder or a file that is not UTF-8, as a warning by default.
108
+ */
109
+ warn = /* @__PURE__ */ __name((message) => console.log(`warn: ${message}`), "warn");
80
110
  constructor(options, root = process.cwd(), custom = {}) {
81
111
  this.root = root;
82
112
  this.ignore = new Ignore(options.ignore);
@@ -88,6 +118,7 @@ var Config = class _Config {
88
118
  const file = join(root, `${name}.config.${extension}`);
89
119
  if (existsSync(file)) {
90
120
  const { default: options } = await import(pathToFileURL(file).href);
121
+ _Config.#check(`${name}.config.${extension}`, options);
91
122
  return new _Config(
92
123
  _Config.merge(defaults, options),
93
124
  root,
@@ -97,12 +128,26 @@ var Config = class _Config {
97
128
  }
98
129
  return new _Config(defaults, root);
99
130
  }
131
+ /** Fails clearly on a config file that does not export the shape of the defaults. */
132
+ static #check(file, options) {
133
+ if (options === void 0) return;
134
+ if (options?.constructor !== Object)
135
+ throw new Error(
136
+ `${file} must export default an object, like { ignore: [], rules: {} }, not ${Array.isArray(options) ? "an array" : typeof options}.`
137
+ );
138
+ const { ignore } = options;
139
+ if (ignore !== void 0 && !(Array.isArray(ignore) && ignore.every((item) => typeof item === "string")))
140
+ throw new Error(`${file}: ignore must be a list of strings.`);
141
+ }
100
142
  /** Handles `--config`, writing the defaults to a TypeScript config file, or with `--config js` a JavaScript one. Returns whether it was given. */
101
143
  static init(name, defaults, argv = process.argv.slice(2), root = process.cwd()) {
102
- const index = argv.indexOf("--config");
144
+ const index = argv.findIndex(
145
+ (arg) => arg === "--config" || arg.startsWith("--config=")
146
+ );
103
147
  if (index === -1) return false;
104
- const next = argv[index + 1];
105
- const extension = next === void 0 || next.startsWith("-") ? "ts" : next;
148
+ const [, inline] = argv[index].split(/=(.*)/s);
149
+ const next = inline ?? argv[index + 1];
150
+ const extension = next === void 0 || next === "" || next.startsWith("-") ? "ts" : next;
106
151
  try {
107
152
  console.log(
108
153
  `created: ${_Config.export(name, defaults, extension, root)}`
@@ -162,24 +207,93 @@ ${items.map((item) => `${inner}${item},
162
207
  merged[key] = _Config.merge(base[key], value);
163
208
  return merged;
164
209
  }
210
+ /**
211
+ * The files that are not ignored, relative to the root. Symlinks are
212
+ * followed, a folder by every path to it, but not into a folder it is in,
213
+ * which would never end; broken symlinks and unreadable folders are
214
+ * reported and left out, like pipes and other special files.
215
+ */
165
216
  *files(directory = "") {
166
- const entries = readdirSync(join(this.root, directory), {
167
- withFileTypes: true
168
- });
217
+ yield* this.#walk(directory, /* @__PURE__ */ new Set());
218
+ }
219
+ *#walk(directory, ancestors) {
220
+ const absolute = join(this.root, directory);
221
+ let entries;
222
+ let real;
223
+ try {
224
+ real = realpathSync(absolute);
225
+ if (ancestors.has(real)) return;
226
+ entries = readdirSync(absolute, { withFileTypes: true });
227
+ } catch (error) {
228
+ this.warn(
229
+ `${directory || "."}: the folder cannot be read (${_Config.#reason(error)}), left out.`
230
+ );
231
+ return;
232
+ }
233
+ ancestors.add(real);
169
234
  for (const entry of entries) {
170
235
  const path = directory ? `${directory}/${entry.name}` : entry.name;
171
- if (this.ignore.matches(path)) continue;
172
- if (entry.isDirectory()) yield* this.files(path);
173
- else yield path;
236
+ let kind = entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "other";
237
+ if (entry.isSymbolicLink())
238
+ try {
239
+ const target = statSync(join(this.root, path));
240
+ kind = target.isDirectory() ? "directory" : target.isFile() ? "file" : "other";
241
+ } catch (error) {
242
+ if (!this.ignore.matches(path))
243
+ this.warn(
244
+ `${path}: a broken symlink (${_Config.#reason(error)}), left out.`
245
+ );
246
+ continue;
247
+ }
248
+ if (this.ignore.matches(path, kind === "directory")) continue;
249
+ if (kind === "directory") yield* this.#walk(path, ancestors);
250
+ else if (kind === "file") yield path;
174
251
  }
252
+ ancestors.delete(real);
175
253
  }
176
- /** Text files that are not ignored and that accepts takes, binaries are skipped. */
254
+ /**
255
+ * Text files that are not ignored and that accepts takes. Binaries, with
256
+ * a NUL byte, are skipped; files that are not UTF-8 are reported and
257
+ * skipped, as writing them back as UTF-8 would change their text.
258
+ */
177
259
  *sources(accepts = () => true) {
260
+ const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
178
261
  for (const file of this.files()) {
179
262
  if (!accepts(file)) continue;
180
263
  const path = join(this.root, file);
181
- const text = readFileSync(path, "utf8");
182
- if (!text.includes("\0")) yield { file, path, text };
264
+ let bytes;
265
+ try {
266
+ bytes = readFileSync(path);
267
+ } catch (error) {
268
+ this.warn(
269
+ `${file}: the file cannot be read (${_Config.#reason(error)}), left out.`
270
+ );
271
+ continue;
272
+ }
273
+ if (bytes.includes(0)) continue;
274
+ let text;
275
+ try {
276
+ text = utf8.decode(bytes);
277
+ } catch {
278
+ this.warn(`${file}: not UTF-8, left out.`);
279
+ continue;
280
+ }
281
+ yield { file, path, text };
282
+ }
283
+ }
284
+ /** Why a file system call failed, in words. */
285
+ static #reason(error) {
286
+ const code = error?.code;
287
+ switch (code) {
288
+ case "ENOENT":
289
+ return "its target does not exist";
290
+ case "EACCES":
291
+ case "EPERM":
292
+ return "no permission";
293
+ case "ELOOP":
294
+ return "a loop of symlinks";
295
+ default:
296
+ return error instanceof Error ? error.message : String(error);
183
297
  }
184
298
  }
185
299
  };
@@ -190,9 +304,10 @@ var Includes = class {
190
304
  __name(this, "Includes");
191
305
  }
192
306
  #globs;
193
- constructor(globs) {
307
+ /** With the name of the setting the globs come from, for their errors. */
308
+ constructor(globs, setting = "includes") {
194
309
  this.#globs = globs.map((glob) => ({
195
- pattern: new Pattern(glob.replace(/^!/, "")),
310
+ pattern: new Pattern(glob.replace(/^!/, ""), setting),
196
311
  included: !glob.startsWith("!")
197
312
  }));
198
313
  }
@@ -17,6 +17,11 @@ export default class Config<Rules extends object = object> {
17
17
  readonly rules: Rules;
18
18
  /** What the config file set, before the defaults were merged in. */
19
19
  readonly custom: Partial<Options<Rules>>;
20
+ /**
21
+ * Reports files and folders that are left out, like a broken symlink, an
22
+ * unreadable folder or a file that is not UTF-8, as a warning by default.
23
+ */
24
+ warn: (message: string) => void;
20
25
  constructor(options: Options<Rules>, root?: string, custom?: Partial<Options<Rules>>);
21
26
  static load<Rules extends object>(name: string, defaults: Options<Rules>, root?: string): Promise<Config<Rules>>;
22
27
  /** Handles `--config`, writing the defaults to a TypeScript config file, or with `--config js` a JavaScript one. Returns whether it was given. */
@@ -25,7 +30,17 @@ export default class Config<Rules extends object = object> {
25
30
  static export(name: string, defaults: Options, extension?: string, root?: string): string;
26
31
  /** Deep merges plain objects, anything else in override replaces base. */
27
32
  static merge<T>(base: T, override: unknown): T;
33
+ /**
34
+ * The files that are not ignored, relative to the root. Symlinks are
35
+ * followed, a folder by every path to it, but not into a folder it is in,
36
+ * which would never end; broken symlinks and unreadable folders are
37
+ * reported and left out, like pipes and other special files.
38
+ */
28
39
  files(directory?: string): Generator<string>;
29
- /** Text files that are not ignored and that accepts takes, binaries are skipped. */
40
+ /**
41
+ * Text files that are not ignored and that accepts takes. Binaries, with
42
+ * a NUL byte, are skipped; files that are not UTF-8 are reported and
43
+ * skipped, as writing them back as UTF-8 would change their text.
44
+ */
30
45
  sources(accepts?: (file: string) => boolean): Generator<Source>;
31
46
  }
@@ -1,5 +1,6 @@
1
1
  export default class Ignore {
2
2
  #private;
3
3
  constructor(sources: readonly string[]);
4
- matches(path: string): boolean;
4
+ /** Whether a path is ignored, globs ending with `/` only ignore folders. */
5
+ matches(path: string, directory?: boolean): boolean;
5
6
  }
@@ -1,6 +1,7 @@
1
- /** Biome style `includes`: globs in order, the last one matching a file decides, `!` excludes. */
1
+ /** `includes`: globs in order, the last one matching a file decides, `!` excludes. */
2
2
  export default class Includes {
3
3
  #private;
4
- constructor(globs: readonly string[]);
4
+ /** With the name of the setting the globs come from, for their errors. */
5
+ constructor(globs: readonly string[], setting?: string);
5
6
  matches(file: string): boolean;
6
7
  }
@@ -1,5 +1,16 @@
1
+ /**
2
+ * A glob of paths relative to the root, like `.gitignore`'s: one without a
3
+ * `/` matches a file or folder of that name anywhere, one with a `/` a path
4
+ * from the root, also when it starts with `/` or `./`, and one that ends
5
+ * with `/` only folders. A folder that matches also matches its contents.
6
+ */
1
7
  export default class Pattern {
2
8
  #private;
3
- constructor(source: string);
4
- matches(path: string): boolean;
9
+ /** With the name of the setting the glob comes from, for its errors. */
10
+ constructor(source: string, setting?: string);
11
+ /**
12
+ * Whether a path, or a folder it is in, matches. Whether the path itself
13
+ * is a folder matters to globs ending with `/`.
14
+ */
15
+ matches(path: string, directory?: boolean): boolean;
5
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aljazmc/config",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Shared, dependency-free configuration loader, file finder and glob matcher for @aljazmc packages.",
5
5
  "author": "aljazmc",
6
6
  "license": "MIT",
@@ -31,7 +31,6 @@
31
31
  }
32
32
  },
33
33
  "devDependencies": {
34
- "@jest/globals": "^30.5.2",
35
- "@types/node": "^26.6.5"
34
+ "@types/node": "^26.6.6"
36
35
  }
37
36
  }