@x18n/core 0.1.9 → 0.1.10

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/dist/index.d.mts CHANGED
@@ -561,6 +561,8 @@ interface CheckContext {
561
561
  readonly usages: () => Iterable<Usage>;
562
562
  readonly coverage: () => readonly LocaleCoverage[];
563
563
  readonly unused: () => readonly KeyString[];
564
+ /** Fully dynamic usages in a scope (`keyPrefix`, namespace) that may reach the key. `planUnused` keeps a key they reach. */
565
+ readonly dynamicUsages: (key: KeyString) => readonly Usage[];
564
566
  /** Usages whose key is not defined in the source locale. */
565
567
  readonly undefinedUsages: () => readonly Usage[];
566
568
  readonly placeholders: (value: string) => readonly Placeholder[];
@@ -597,6 +599,20 @@ interface ReportInput {
597
599
  readonly diagnostics: readonly Diagnostic[];
598
600
  readonly coverage: readonly LocaleCoverage[];
599
601
  readonly summary: ReportSummary;
602
+ /** Set with `x18n check --fix`; `diagnostics` are then the ones left after the fixes (or before them, in a dry run). */
603
+ readonly fix?: FixSummary | undefined;
604
+ }
605
+ /** What `x18n check --fix` did in a project. */
606
+ interface FixSummary {
607
+ /** False in a dry run. */
608
+ readonly applied: boolean;
609
+ /** Diagnostics whose fix is in the plan. */
610
+ readonly fixed: number;
611
+ /** Diagnostics left out because their only bulk fix is `unsafe` (pass `--fix=unsafe`). */
612
+ readonly skipped: number;
613
+ /** Files the plan writes, relative to the project root. */
614
+ readonly files: readonly string[];
615
+ readonly warnings: readonly string[];
600
616
  }
601
617
  /** What a check baseline left out (`x18n check --baseline`). */
602
618
  interface BaselineSummary {
@@ -1157,11 +1173,13 @@ interface Usage {
1157
1173
  readonly scopePrefix: string | undefined;
1158
1174
  }
1159
1175
  type Severity = "error" | "warning" | "info" | "hint";
1176
+ /** A `set` with `replace: false` is skipped, with a warning, when it would replace other keys or a message on its path. */
1160
1177
  type EditOp = {
1161
1178
  readonly type: "set";
1162
1179
  readonly key: KeyString;
1163
1180
  readonly locale: LocaleCode;
1164
1181
  readonly value: MessageLeaf;
1182
+ readonly replace?: boolean;
1165
1183
  } | {
1166
1184
  readonly type: "rename";
1167
1185
  readonly from: KeyString;
@@ -1215,9 +1233,15 @@ interface ExtractItem {
1215
1233
  /** Lines to add once per file when missing, for example an import. */
1216
1234
  readonly imports?: readonly string[];
1217
1235
  }
1236
+ /**
1237
+ * How `x18n check --fix` may apply a fix. `safe` fixes only add, so `--fix` applies them; `unsafe` fixes can lose data, so
1238
+ * only `--fix=unsafe` applies them. A fix without a level is offered in the editor and never applied in bulk.
1239
+ */
1240
+ type FixLevel = "safe" | "unsafe";
1218
1241
  interface Fix {
1219
1242
  readonly title: string;
1220
1243
  readonly ops: readonly EditOp[];
1244
+ readonly level?: FixLevel | undefined;
1221
1245
  }
1222
1246
  interface Location {
1223
1247
  readonly path: string;
@@ -1659,6 +1683,20 @@ interface UnusedDeletion {
1659
1683
  /** Sorted by key. */
1660
1684
  readonly kept: readonly KeptUnusedKey[];
1661
1685
  }
1686
+ /** Which fixes `planFixes` applies. */
1687
+ interface FixQuery {
1688
+ /** Also apply `unsafe` fixes (`x18n check --fix=unsafe`). */
1689
+ readonly unsafe?: boolean | undefined;
1690
+ }
1691
+ /** The fixes of many diagnostics, as one plan: review it (dry run), then pass `plan` to `apply`. */
1692
+ interface FixPlan {
1693
+ /** Deletes first, then the other ops, each planned once. */
1694
+ readonly plan: Plan;
1695
+ /** The diagnostics whose fix is in the plan. */
1696
+ readonly fixed: readonly Diagnostic[];
1697
+ /** Diagnostics left out because their only bulk fix is `unsafe` and the query does not allow it. */
1698
+ readonly skipped: readonly Diagnostic[];
1699
+ }
1662
1700
  interface ProjectExplanation {
1663
1701
  readonly name: string;
1664
1702
  readonly root: string;
@@ -1804,6 +1842,8 @@ interface Project {
1804
1842
  readonly plan: (ops: readonly EditOp[]) => Plan;
1805
1843
  /** Plans the deletion of the unused keys (`analyze().unused`), minus `ignoreKeys` and keys a scoped fully dynamic usage may reach. */
1806
1844
  readonly planUnused: (query?: UnusedQuery) => UnusedDeletion;
1845
+ /** Plans the first fix of each diagnostic that has a `safe` fix, or also an `unsafe` one with `unsafe: true` (`x18n check --fix`). */
1846
+ readonly planFixes: (diagnostics: readonly Diagnostic[], query?: FixQuery) => FixPlan;
1807
1847
  readonly apply: (plan: Plan, options?: {
1808
1848
  readonly answers?: Answers | undefined;
1809
1849
  }) => Promise<ApplyResult>;
@@ -1940,10 +1980,14 @@ interface LineIndex {
1940
1980
  readonly line: (line: number) => string;
1941
1981
  readonly lineCount: number;
1942
1982
  }
1943
- /** Line index of a text, cached for the few texts used at a time (reporters, LSP ranges). */
1983
+ /**
1984
+ * Line index of a text, cached for the few texts used at a time (reporters, LSP ranges).
1985
+ * The least recently used text goes first, so a large resource hit by many diagnostics stays
1986
+ * cached while ranges in many small source files pass through.
1987
+ */
1944
1988
  export declare function createLineIndex(text: string): LineIndex;
1945
1989
  export declare function rangeContains(range: TextRange, offset: number): boolean;
1946
1990
  /** Fast non-cryptographic string hash (cyrb53), as hex. */
1947
1991
  export declare function hashText(text: string): string;
1948
1992
  //#endregion
1949
- export type { Analysis, Answers, ApplyResult, ArgSource, BaselineSummary, Builtins, CacheStore, CallSpec, CapabilityConflict, Check, CheckContext, CheckIds, CheckLevel, CheckSetting, ConfigContext, ConfigDraft, ConfigOrigin, CreateProjectOptions, Decision, DecisionOption, DetectContext, Diagnostic, DiagnosticInput, Disposable, EditOp, Envelope, ErrorCode, ErrorInfo, ExtractItem, ExtractOptions, ExtractTemplate, Extractor, FileChange, FileEvent, FileScan, FileWrite, FinderContext, Fix, Format, FormatContext, FormatEntry, GlobOptions, GlossaryEntry, HardString, HistoryConfigs, HistoryContext, HistoryOptions, HistoryProvider, HistorySettings, Host, JsonObject, JsonPrimitive, JsonValue, KeptUnusedKey, KeyContext, KeyFile, KeyId, KeyInfo, KeyPattern, KeyQuery, KeySeparator, KeyString, KeyTarget, LazyFormat, LazyScanner, LibraryOptions, LineIndex, LinePosition, LocaleCode, LocaleCoverage, LocaleFinder, LocaleInfo, Location, Logger, ManifestReader, MemoryHost, Message, MessageChange, MessageHistory, MessageLeaf, MessageOp, MessageState, MessageTree, NamespaceFromPath, NamespaceOptions, NodeRange, OverlayHost, ParamsSource, ParseResult, Placeholder, PlaceholderComparer, PlaceholderProblem, PlaceholderSyntax, Plan, Plugin, PluginConfigs, PluginFile, PluginLoader, PluginOption, PluginRegistry, PluginSpecifier, PluralForm, Preset, PresetDetection, PresetEntry, PresetExplanation, PresetFactory, PresetLoader, PresetOption, Project, ProjectChange, ProjectExplanation, PromptContext, PromptData, PromptDraft, PromptIssue, PromptItem, PromptOptions, RegexScannerSpec, RelatedValue, ReportInput, ReportSummary, Reporter, ReporterContext, ReporterLoader, ResolveConfigOptions, ResolvedConfig, Resource, ResourceBlock, ResourceCandidate, ResourceId, ResourceNode, ResourceSpec, ResourceTransform, ResourceTransformContext, ReviewAuthor, ReviewComment, ReviewData, ReviewEdit, ReviewEditContext, ReviewFile, ReviewFilter, ReviewOptions, ReviewState, ReviewStore, ReviewThread, ReviewType, ScanContext, ScanResult, Scanner, ScannerEntry, SchemaIssue, Scope, SearchMatch, SearchOptions, SerializableConfig, Setup, Severity, SourceFile, TextEdit, TextRange, ThreadComment, TreeLeaf, UnusedDeletion, UnusedKeyDeletion, UnusedQuery, Usage, UsageCandidate, UsageContext, UsageKind, UsageMatch, UsageOptions, UsageQuery, UserConfig, WireDiagnostic, WireRange, WriteOptions, X18nError };
1993
+ export type { Analysis, Answers, ApplyResult, ArgSource, BaselineSummary, Builtins, CacheStore, CallSpec, CapabilityConflict, Check, CheckContext, CheckIds, CheckLevel, CheckSetting, ConfigContext, ConfigDraft, ConfigOrigin, CreateProjectOptions, Decision, DecisionOption, DetectContext, Diagnostic, DiagnosticInput, Disposable, EditOp, Envelope, ErrorCode, ErrorInfo, ExtractItem, ExtractOptions, ExtractTemplate, Extractor, FileChange, FileEvent, FileScan, FileWrite, FinderContext, Fix, FixLevel, FixPlan, FixQuery, FixSummary, Format, FormatContext, FormatEntry, GlobOptions, GlossaryEntry, HardString, HistoryConfigs, HistoryContext, HistoryOptions, HistoryProvider, HistorySettings, Host, JsonObject, JsonPrimitive, JsonValue, KeptUnusedKey, KeyContext, KeyFile, KeyId, KeyInfo, KeyPattern, KeyQuery, KeySeparator, KeyString, KeyTarget, LazyFormat, LazyScanner, LibraryOptions, LineIndex, LinePosition, LocaleCode, LocaleCoverage, LocaleFinder, LocaleInfo, Location, Logger, ManifestReader, MemoryHost, Message, MessageChange, MessageHistory, MessageLeaf, MessageOp, MessageState, MessageTree, NamespaceFromPath, NamespaceOptions, NodeRange, OverlayHost, ParamsSource, ParseResult, Placeholder, PlaceholderComparer, PlaceholderProblem, PlaceholderSyntax, Plan, Plugin, PluginConfigs, PluginFile, PluginLoader, PluginOption, PluginRegistry, PluginSpecifier, PluralForm, Preset, PresetDetection, PresetEntry, PresetExplanation, PresetFactory, PresetLoader, PresetOption, Project, ProjectChange, ProjectExplanation, PromptContext, PromptData, PromptDraft, PromptIssue, PromptItem, PromptOptions, RegexScannerSpec, RelatedValue, ReportInput, ReportSummary, Reporter, ReporterContext, ReporterLoader, ResolveConfigOptions, ResolvedConfig, Resource, ResourceBlock, ResourceCandidate, ResourceId, ResourceNode, ResourceSpec, ResourceTransform, ResourceTransformContext, ReviewAuthor, ReviewComment, ReviewData, ReviewEdit, ReviewEditContext, ReviewFile, ReviewFilter, ReviewOptions, ReviewState, ReviewStore, ReviewThread, ReviewType, ScanContext, ScanResult, Scanner, ScannerEntry, SchemaIssue, Scope, SearchMatch, SearchOptions, SerializableConfig, Setup, Severity, SourceFile, TextEdit, TextRange, ThreadComment, TreeLeaf, UnusedDeletion, UnusedKeyDeletion, UnusedQuery, Usage, UsageCandidate, UsageContext, UsageKind, UsageMatch, UsageOptions, UsageQuery, UserConfig, WireDiagnostic, WireRange, WriteOptions, X18nError };
package/dist/index.mjs CHANGED
@@ -29,14 +29,19 @@ function applyTextEdits(text, edits) {
29
29
  return out;
30
30
  }
31
31
  const lineIndexes = /* @__PURE__ */ new Map();
32
- /** Line index of a text, cached for the few texts used at a time (reporters, LSP ranges). */
32
+ /**
33
+ * Line index of a text, cached for the few texts used at a time (reporters, LSP ranges).
34
+ * The least recently used text goes first, so a large resource hit by many diagnostics stays
35
+ * cached while ranges in many small source files pass through.
36
+ */
33
37
  function createLineIndex(text) {
34
38
  let index = lineIndexes.get(text);
35
- if (!index) {
39
+ if (index) lineIndexes.delete(text);
40
+ else {
36
41
  if (lineIndexes.size >= 16) lineIndexes.delete(lineIndexes.keys().next().value);
37
42
  index = buildLineIndex(text);
38
- lineIndexes.set(text, index);
39
43
  }
44
+ lineIndexes.set(text, index);
40
45
  return index;
41
46
  }
42
47
  function buildLineIndex(text) {
@@ -1119,6 +1124,9 @@ function renderExplanation(e, configFile) {
1119
1124
  return lines.join("\n");
1120
1125
  }
1121
1126
  //#endregion
1127
+ //#region package.json
1128
+ var version = "0.1.10";
1129
+ //#endregion
1122
1130
  //#region src/project/history.ts
1123
1131
  const localChange = { updatedAt: Number.POSITIVE_INFINITY };
1124
1132
  /** A target message is stale when it last changed before its source message. */
@@ -1172,6 +1180,90 @@ function createHistory(input) {
1172
1180
  };
1173
1181
  }
1174
1182
  //#endregion
1183
+ //#region src/project/unused.ts
1184
+ /**
1185
+ * True when a fully dynamic usage (`t(key)`) may reach `key`: the key lies in the usage's scope namespace and under its
1186
+ * scope key prefix. Undefined for a usage without a scope, which may reach any key.
1187
+ */
1188
+ function scopeMatcher(usage, namespaced) {
1189
+ const namespace = namespaced ? usage.scopeNamespace : void 0;
1190
+ const prefix = usage.scopePrefix;
1191
+ if (namespace === void 0 && !prefix) return void 0;
1192
+ return (key) => {
1193
+ const id = splitKey(key, namespaced);
1194
+ if (namespace !== void 0 && id.namespace !== namespace) return false;
1195
+ if (!prefix) return true;
1196
+ const path = id.path.join(".");
1197
+ return path === prefix || path.startsWith(`${prefix}.`);
1198
+ };
1199
+ }
1200
+ function dynamicReach(usages, namespaced) {
1201
+ const scoped = [];
1202
+ const unscoped = [];
1203
+ for (const usage of usages) {
1204
+ if (usage.dynamic !== "unknown") continue;
1205
+ const reaches = scopeMatcher(usage, namespaced);
1206
+ if (reaches) scoped.push({
1207
+ usage,
1208
+ reaches
1209
+ });
1210
+ else unscoped.push(usage);
1211
+ }
1212
+ return {
1213
+ reaching: (key) => scoped.filter((s) => s.reaches(key)).map((s) => s.usage),
1214
+ unscoped
1215
+ };
1216
+ }
1217
+ /** The plan warning for deleting unused keys while fully dynamic usages without a scope may reach any key. */
1218
+ function unscopedWarning(unscoped) {
1219
+ const files = [...new Set(unscoped.map((usage) => usage.path))].sort();
1220
+ const shown = files.length > 5 ? `${files.slice(0, 5).join(", ")} and ${files.length - 5} more files` : files.join(", ");
1221
+ return `${unscoped.length} usage${unscoped.length === 1 ? " has" : "s have"} a fully dynamic key (in ${shown}) that may reach any key; list the keys they use in \`usage.keysInUse\` to keep them.`;
1222
+ }
1223
+ function planUnusedDeletion(options, query = {}) {
1224
+ const ignored = keyMatcher(options.ignoreKeys);
1225
+ const match = query.glob ? keyMatcher([query.glob]) : void 0;
1226
+ const candidates = [...options.unused].filter((key) => !ignored(key) && (match === void 0 || match(key)) && (query.namespace === void 0 || splitKey(key, options.namespaced).namespace === query.namespace)).sort();
1227
+ const reach = dynamicReach(options.usages, options.namespaced);
1228
+ const keys = [];
1229
+ const kept = [];
1230
+ for (const key of candidates) {
1231
+ const usages = reach.reaching(key);
1232
+ if (usages.length > 0) {
1233
+ kept.push({
1234
+ key,
1235
+ usages
1236
+ });
1237
+ continue;
1238
+ }
1239
+ keys.push({
1240
+ key,
1241
+ messages: options.locations(key).filter((node) => node.leaf).map((node) => ({
1242
+ locale: node.locale,
1243
+ path: node.path
1244
+ }))
1245
+ });
1246
+ }
1247
+ const plan = options.plan(keys.map(({ key }) => ({
1248
+ type: "delete",
1249
+ key,
1250
+ locales: "all"
1251
+ })));
1252
+ if (reach.unscoped.length === 0 || keys.length === 0) return {
1253
+ plan,
1254
+ keys,
1255
+ kept
1256
+ };
1257
+ return {
1258
+ plan: {
1259
+ ...plan,
1260
+ warnings: [...plan.warnings, unscopedWarning(reach.unscoped)]
1261
+ },
1262
+ keys,
1263
+ kept
1264
+ };
1265
+ }
1266
+ //#endregion
1175
1267
  //#region src/project/usages.ts
1176
1268
  function innermost(scopes, range) {
1177
1269
  let best;
@@ -1510,6 +1602,7 @@ function createAnalyzer(options) {
1510
1602
  return prefixes.some((prefix) => key.startsWith(prefix));
1511
1603
  };
1512
1604
  const unused = hasScanners ? allKeys.filter((key) => !isUsed(key)) : [];
1605
+ const reach = dynamicReach(state.usages, options.namespaced());
1513
1606
  const undefinedUsages = state.usages.filter((usage) => usage.dynamic === false && usage.key !== void 0 && !isDefined(usage.key));
1514
1607
  const placeholders = (value) => {
1515
1608
  let found = placeholderCache.get(value);
@@ -1537,6 +1630,7 @@ function createAnalyzer(options) {
1537
1630
  usages: () => state.usages,
1538
1631
  coverage: () => coverage,
1539
1632
  unused: () => unused,
1633
+ dynamicUsages: reach.reaching,
1540
1634
  undefinedUsages: () => undefinedUsages,
1541
1635
  placeholders,
1542
1636
  placeholderSyntaxes: registry.placeholders,
@@ -1572,6 +1666,55 @@ function createAnalyzer(options) {
1572
1666
  return { analyze };
1573
1667
  }
1574
1668
  //#endregion
1669
+ //#region src/project/fixes.ts
1670
+ /** The first fix of a diagnostic that the query allows: `safe`, or also `unsafe` with `unsafe: true`. */
1671
+ function pick(diagnostic, unsafe) {
1672
+ return diagnostic.fixes.find((fix) => fix.level === "safe" || unsafe && fix.level === "unsafe");
1673
+ }
1674
+ /** Identifies ops that do the same: a second `set` of a key in a locale is dropped, so the first value wins. */
1675
+ function opId(op) {
1676
+ return op.type === "set" ? JSON.stringify([
1677
+ op.type,
1678
+ op.key,
1679
+ op.locale
1680
+ ]) : JSON.stringify(op);
1681
+ }
1682
+ /**
1683
+ * Plans the fixes of many diagnostics as one plan. Deletes go first, so a deleted message makes room for a key that a
1684
+ * later `set` with `replace: false` adds under it. Identical ops are planned once.
1685
+ */
1686
+ function planFixes(diagnostics, options, query = {}) {
1687
+ const unsafe = query.unsafe === true;
1688
+ const fixed = [];
1689
+ const skipped = [];
1690
+ const deletes = /* @__PURE__ */ new Map();
1691
+ const others = /* @__PURE__ */ new Map();
1692
+ for (const diagnostic of diagnostics) {
1693
+ const fix = pick(diagnostic, unsafe);
1694
+ if (!fix) {
1695
+ if (diagnostic.fixes.some((f) => f.level === "unsafe")) skipped.push(diagnostic);
1696
+ continue;
1697
+ }
1698
+ fixed.push(diagnostic);
1699
+ for (const op of fix.ops) {
1700
+ const ops = op.type === "delete" ? deletes : others;
1701
+ const id = opId(op);
1702
+ if (!ops.has(id)) ops.set(id, op);
1703
+ }
1704
+ }
1705
+ const plan = options.plan([...deletes.values(), ...others.values()]);
1706
+ const deletesEverywhere = [...deletes.values()].some((op) => op.type === "delete" && (op.locales === void 0 || op.locales === "all"));
1707
+ const { unscoped } = dynamicReach(options.usages, options.namespaced);
1708
+ return {
1709
+ plan: deletesEverywhere && unscoped.length > 0 ? {
1710
+ ...plan,
1711
+ warnings: [...plan.warnings, unscopedWarning(unscoped)]
1712
+ } : plan,
1713
+ fixed,
1714
+ skipped
1715
+ };
1716
+ }
1717
+ //#endregion
1575
1718
  //#region src/project/reviews.ts
1576
1719
  const noReviews = {
1577
1720
  threads: {},
@@ -2004,10 +2147,14 @@ var PlanBuilder = class {
2004
2147
  if ((before !== void 0 && !isMessageLeaf(before)) !== (after !== void 0 && !isMessageLeaf(after)) || before !== void 0 && after === void 0) entry.flat = void 0;
2005
2148
  }
2006
2149
  }
2007
- setValue(key, locale, value) {
2008
- this.touched.add(key);
2150
+ /**
2151
+ * Returns the value the set replaced in `before`. Undefined when `replace` is false and the value would replace other keys
2152
+ * or a message on its path: the set is then skipped with a warning.
2153
+ */
2154
+ setValue(key, locale, value, replace = true) {
2009
2155
  const existing = this.options.message(key, locale);
2010
2156
  if (existing) {
2157
+ this.touched.add(key);
2011
2158
  const entry = this.workFor(this.targetOf(this.writable(existing.resource)));
2012
2159
  const before = leafAt(entry.tree, existing.storedPath);
2013
2160
  this.pushOp(entry, {
@@ -2015,20 +2162,36 @@ var PlanBuilder = class {
2015
2162
  path: existing.storedPath,
2016
2163
  value
2017
2164
  });
2018
- return before;
2165
+ return { before };
2019
2166
  }
2020
2167
  const target = this.pickTarget(key, locale);
2021
2168
  if (target.state) this.writable(target.state.resource.id);
2022
2169
  const entry = this.workFor(target);
2023
2170
  const stored = this.storedPathFor(entry, this.relativePath(key, target));
2024
2171
  const occupied = nodeAt(entry.tree, stored);
2025
- if (occupied !== void 0 && !isMessageLeaf(occupied)) this.warnings.push(`"${key}" is a parent of other keys in ${target.path}; they are replaced.`);
2172
+ const parent = occupied !== void 0 && !isMessageLeaf(occupied);
2173
+ if (!replace) {
2174
+ const leaf = stored.slice(0, -1).findIndex((_, i) => {
2175
+ const node = nodeAt(entry.tree, stored.slice(0, i + 1));
2176
+ return node !== void 0 && isMessageLeaf(node);
2177
+ });
2178
+ if (leaf !== -1) {
2179
+ const message = stored.slice(0, leaf + 1).join(keyJoiner(this.separatorOf(target)));
2180
+ this.warnings.push(`"${key}" is not added: "${message}" is a message in ${target.path}, and adding "${key}" would replace it.`);
2181
+ return;
2182
+ }
2183
+ if (parent) {
2184
+ this.warnings.push(`"${key}" is not added: it is a parent of other keys in ${target.path}, and adding it would replace them.`);
2185
+ return;
2186
+ }
2187
+ } else if (parent) this.warnings.push(`"${key}" is a parent of other keys in ${target.path}; they are replaced.`);
2188
+ this.touched.add(key);
2026
2189
  this.pushOp(entry, {
2027
2190
  type: "set",
2028
2191
  path: stored,
2029
2192
  value
2030
2193
  });
2031
- return isMessageLeaf(occupied) ? occupied : void 0;
2194
+ return { before: isMessageLeaf(occupied) ? occupied : void 0 };
2032
2195
  }
2033
2196
  deleteMessage(m) {
2034
2197
  const s = this.writable(m.resource);
@@ -2102,7 +2265,7 @@ var PlanBuilder = class {
2102
2265
  const own = this.options.resolveKey(to, m.locale);
2103
2266
  if (!relative || own && own.path !== s.resource.path) {
2104
2267
  this.deleteMessage(m);
2105
- return this.setValue(to, m.locale, m.value);
2268
+ return this.setValue(to, m.locale, m.value)?.before;
2106
2269
  }
2107
2270
  const entry = this.workFor(this.targetOf(s));
2108
2271
  const separator = this.separatorOf(s.resource);
@@ -2313,11 +2476,12 @@ var PlanBuilder = class {
2313
2476
  for (const locale of locales) this.recordSource(op.key, locale, true);
2314
2477
  }
2315
2478
  set(op) {
2316
- this.setValue(op.key, op.locale, op.value);
2479
+ const replace = op.replace !== false;
2480
+ if (!this.setValue(op.key, op.locale, op.value, replace)) return;
2317
2481
  if (op.locale === this.state.sourceLocale) this.plannedSource.set(op.key, op.value);
2318
2482
  else if (op.value !== "") this.recordSource(op.key, op.locale);
2319
2483
  if (!this.config.write.keepFilled) return;
2320
- for (const locale of this.state.locales) if (locale !== op.locale && !this.state.fallbacks[locale] && !this.options.message(op.key, locale)) this.setValue(op.key, locale, "");
2484
+ for (const locale of this.state.locales) if (locale !== op.locale && !this.state.fallbacks[locale] && !this.options.message(op.key, locale)) this.setValue(op.key, locale, "", replace);
2321
2485
  }
2322
2486
  run(ops) {
2323
2487
  for (const op of ops) switch (op.type) {
@@ -2837,80 +3001,6 @@ function renderPrompt(data) {
2837
3001
  return out.join("\n");
2838
3002
  }
2839
3003
  //#endregion
2840
- //#region src/project/unused.ts
2841
- /**
2842
- * True when a fully dynamic usage (`t(key)`) may reach `key`: the key lies in the usage's scope namespace and under its
2843
- * scope key prefix. Undefined for a usage without a scope, which may reach any key.
2844
- */
2845
- function scopeMatcher(usage, namespaced) {
2846
- const namespace = namespaced ? usage.scopeNamespace : void 0;
2847
- const prefix = usage.scopePrefix;
2848
- if (namespace === void 0 && !prefix) return void 0;
2849
- return (key) => {
2850
- const id = splitKey(key, namespaced);
2851
- if (namespace !== void 0 && id.namespace !== namespace) return false;
2852
- if (!prefix) return true;
2853
- const path = id.path.join(".");
2854
- return path === prefix || path.startsWith(`${prefix}.`);
2855
- };
2856
- }
2857
- function planUnusedDeletion(options, query = {}) {
2858
- const ignored = keyMatcher(options.ignoreKeys);
2859
- const match = query.glob ? keyMatcher([query.glob]) : void 0;
2860
- const candidates = [...options.unused].filter((key) => !ignored(key) && (match === void 0 || match(key)) && (query.namespace === void 0 || splitKey(key, options.namespaced).namespace === query.namespace)).sort();
2861
- const scoped = [];
2862
- const unscoped = [];
2863
- for (const usage of options.usages) {
2864
- if (usage.dynamic !== "unknown") continue;
2865
- const reaches = scopeMatcher(usage, options.namespaced);
2866
- if (reaches) scoped.push({
2867
- usage,
2868
- reaches
2869
- });
2870
- else unscoped.push(usage);
2871
- }
2872
- const keys = [];
2873
- const kept = [];
2874
- for (const key of candidates) {
2875
- const usages = scoped.filter((s) => s.reaches(key)).map((s) => s.usage);
2876
- if (usages.length > 0) {
2877
- kept.push({
2878
- key,
2879
- usages
2880
- });
2881
- continue;
2882
- }
2883
- keys.push({
2884
- key,
2885
- messages: options.locations(key).filter((node) => node.leaf).map((node) => ({
2886
- locale: node.locale,
2887
- path: node.path
2888
- }))
2889
- });
2890
- }
2891
- const plan = options.plan(keys.map(({ key }) => ({
2892
- type: "delete",
2893
- key,
2894
- locales: "all"
2895
- })));
2896
- if (unscoped.length === 0 || keys.length === 0) return {
2897
- plan,
2898
- keys,
2899
- kept
2900
- };
2901
- const files = [...new Set(unscoped.map((usage) => usage.path))].sort();
2902
- const shown = files.length > 5 ? `${files.slice(0, 5).join(", ")} and ${files.length - 5} more files` : files.join(", ");
2903
- const warning = `${unscoped.length} usage${unscoped.length === 1 ? " has" : "s have"} a fully dynamic key (in ${shown}) that may reach any key; list the keys they use in \`usage.keysInUse\` to keep them.`;
2904
- return {
2905
- plan: {
2906
- ...plan,
2907
- warnings: [...plan.warnings, warning]
2908
- },
2909
- keys,
2910
- kept
2911
- };
2912
- }
2913
- //#endregion
2914
3004
  //#region src/project/project.ts
2915
3005
  const ioConcurrency = 64;
2916
3006
  async function forEachLimit(items, limit, fn) {
@@ -2950,8 +3040,10 @@ function createProject(options) {
2950
3040
  const { basesOf, formOf } = derivedKeys(config.usage.derivedKeys);
2951
3041
  const scanSignature = hashText(JSON.stringify({
2952
3042
  usage: config.usage,
3043
+ extract: config.extract,
2953
3044
  namespace: config.namespace,
2954
3045
  scanners: registry.scanners.map((s) => s.id),
3046
+ version,
2955
3047
  v: 1
2956
3048
  }));
2957
3049
  const localeFilter = (code) => (!config.locales || config.locales.includes(code)) && !config.ignoreLocales.includes(code);
@@ -3529,8 +3621,14 @@ function createProject(options) {
3529
3621
  basesOf,
3530
3622
  formOf,
3531
3623
  localeInfo,
3532
- text: (path) => fileTexts.get(path) ?? files.get(path)?.text
3624
+ text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
3625
+ namespaced: () => namespaced
3533
3626
  });
3627
+ let lastAnalysis;
3628
+ function analyze() {
3629
+ if (lastAnalysis?.revision !== revision) lastAnalysis = analyzer.analyze();
3630
+ return lastAnalysis;
3631
+ }
3534
3632
  function keys(query = {}) {
3535
3633
  const { messages, keyPaths } = idx();
3536
3634
  const match = query.glob ? keyMatcher([query.glob], config.keySeparator) : void 0;
@@ -3545,7 +3643,7 @@ function createProject(options) {
3545
3643
  });
3546
3644
  }
3547
3645
  if (query.unused) {
3548
- const unused = new Set(analyzer.analyze().unused);
3646
+ const unused = new Set(analyze().unused);
3549
3647
  list = list.filter((key) => unused.has(key));
3550
3648
  }
3551
3649
  return list.sort();
@@ -3573,6 +3671,24 @@ function createProject(options) {
3573
3671
  }
3574
3672
  return found.sort((a, b) => b.score - a.score || a.value.length - b.value.length || a.key.localeCompare(b.key));
3575
3673
  }
3674
+ let diagnosticIndex;
3675
+ function diagnosticsOf(key) {
3676
+ const analysis = analyze();
3677
+ if (diagnosticIndex?.analysis !== analysis) {
3678
+ const byKey = /* @__PURE__ */ new Map();
3679
+ for (const d of analysis.diagnostics) {
3680
+ if (d.key === void 0) continue;
3681
+ const list = byKey.get(d.key);
3682
+ if (list) list.push(d);
3683
+ else byKey.set(d.key, [d]);
3684
+ }
3685
+ diagnosticIndex = {
3686
+ analysis,
3687
+ byKey
3688
+ };
3689
+ }
3690
+ return [...diagnosticIndex.byKey.get(key) ?? []];
3691
+ }
3576
3692
  function get(key) {
3577
3693
  const { messages, keyPaths } = idx();
3578
3694
  const byLocale = messages.get(key);
@@ -3587,7 +3703,7 @@ function createProject(options) {
3587
3703
  messages: list,
3588
3704
  missing: locales.filter((locale) => !byLocale.has(locale) && needs(key, locale)),
3589
3705
  usages: usageMatches({ key }),
3590
- diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key),
3706
+ diagnostics: diagnosticsOf(key),
3591
3707
  reviews: reviews({ key })
3592
3708
  };
3593
3709
  }
@@ -3809,7 +3925,7 @@ function createProject(options) {
3809
3925
  ...h,
3810
3926
  path: p
3811
3927
  }))),
3812
- analyze: () => analyzer.analyze(),
3928
+ analyze,
3813
3929
  async loadHistory(historyOptions = {}) {
3814
3930
  await ready;
3815
3931
  await history.load(historyOptions);
@@ -3832,13 +3948,18 @@ function createProject(options) {
3832
3948
  owns: (path) => fileTexts.has(path) || files.has(path) || usageEligible(path),
3833
3949
  plan: (ops) => planner.plan(ops),
3834
3950
  planUnused: (query) => planUnusedDeletion({
3835
- unused: analyzer.analyze().unused,
3951
+ unused: analyze().unused,
3836
3952
  usages: idx().usages,
3837
3953
  namespaced,
3838
3954
  ignoreKeys: config.ignoreKeys,
3839
3955
  locations: (key) => project.locations(key),
3840
3956
  plan: (ops) => planner.plan(ops)
3841
3957
  }, query),
3958
+ planFixes: (diagnostics, query) => planFixes(diagnostics, {
3959
+ usages: idx().usages,
3960
+ namespaced,
3961
+ plan: (ops) => planner.plan(ops)
3962
+ }, query),
3842
3963
  apply: (plan, applyOptions) => planner.apply(plan, applyOptions?.answers ?? {}),
3843
3964
  update,
3844
3965
  watch() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x18n/core",
3
3
  "type": "module",
4
- "version": "0.1.9",
4
+ "version": "0.1.10",
5
5
  "description": "Runtime-neutral engine of x18n: domain model, plugin API and project analysis",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",