@diagrammo/dgmo 0.8.19 → 0.8.20

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.
@@ -18,6 +18,7 @@ import { parseInfra } from './infra/parser';
18
18
  import { parseGantt } from './gantt/parser';
19
19
  import { parseBoxesAndLines } from './boxes-and-lines/parser';
20
20
  import { parseFirstLine } from './utils/parsing';
21
+ import { makeDgmoError, suggest } from './diagnostics';
21
22
  import type { DgmoError } from './diagnostics';
22
23
 
23
24
  // ============================================================
@@ -229,6 +230,17 @@ const PARSE_DISPATCH = new Map<
229
230
  ['boxes-and-lines', (c) => parseBoxesAndLines(c)],
230
231
  ]);
231
232
 
233
+ /**
234
+ * Parse DGMO content and return diagnostics without rendering.
235
+ * Useful for the CLI and editor to surface all errors before attempting render.
236
+ */
237
+ /** All known chart type names for colon-pattern detection. */
238
+ const ALL_KNOWN_TYPES = new Set([
239
+ ...DATA_CHART_TYPES,
240
+ ...VISUALIZATION_TYPES,
241
+ ...DIAGRAM_TYPES,
242
+ ]);
243
+
232
244
  /**
233
245
  * Parse DGMO content and return diagnostics without rendering.
234
246
  * Useful for the CLI and editor to surface all errors before attempting render.
@@ -237,20 +249,100 @@ export function parseDgmo(content: string): { diagnostics: DgmoError[] } {
237
249
  const chartType = parseDgmoChartType(content);
238
250
 
239
251
  if (!chartType) {
240
- // No chart type detected try visualization parser as fallback (it handles missing chart: line)
252
+ // Check for common mistake: colon in chart type declaration (e.g. "bar: Sales")
253
+ const colonDiag = detectColonChartType(content);
254
+ if (colonDiag) {
255
+ const fallback = parseVisualization(content).diagnostics;
256
+ return { diagnostics: [colonDiag, ...fallback] };
257
+ }
258
+
259
+ // No chart type detected — try visualization parser as fallback
241
260
  return { diagnostics: parseVisualization(content).diagnostics };
242
261
  }
243
262
 
244
263
  const directParser = PARSE_DISPATCH.get(chartType);
245
- if (directParser) return { diagnostics: directParser(content).diagnostics };
264
+ if (directParser) {
265
+ const result = directParser(content);
266
+ return {
267
+ diagnostics: [...result.diagnostics, ...detectEmptyContent(content)],
268
+ };
269
+ }
246
270
 
247
271
  if (STANDARD_CHART_TYPES.has(chartType)) {
248
- return { diagnostics: parseChart(content).diagnostics };
272
+ const result = parseChart(content);
273
+ return {
274
+ diagnostics: [...result.diagnostics, ...detectEmptyContent(content)],
275
+ };
249
276
  }
250
277
  if (ECHART_TYPES.has(chartType)) {
251
- return { diagnostics: parseExtendedChart(content).diagnostics };
278
+ const result = parseExtendedChart(content);
279
+ return {
280
+ diagnostics: [...result.diagnostics, ...detectEmptyContent(content)],
281
+ };
252
282
  }
253
283
 
254
284
  // Visualization types (slope, wordcloud, arc, timeline, venn, quadrant)
255
- return { diagnostics: parseVisualization(content).diagnostics };
285
+ const result = parseVisualization(content);
286
+ return {
287
+ diagnostics: [...result.diagnostics, ...detectEmptyContent(content)],
288
+ };
289
+ }
290
+
291
+ // ============================================================
292
+ // Common-mistake detectors
293
+ // ============================================================
294
+
295
+ /**
296
+ * Detects colon-separated chart type declarations like "bar: Sales" or "pie: Data".
297
+ * Returns a diagnostic if the word before the colon is a known or similar chart type.
298
+ */
299
+ function detectColonChartType(content: string): DgmoError | null {
300
+ const lines = content.split('\n');
301
+ for (let i = 0; i < lines.length; i++) {
302
+ const trimmed = lines[i].trim();
303
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//'))
304
+ continue;
305
+
306
+ const match = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
307
+ if (!match) return null; // First non-empty line doesn't match colon pattern
308
+
309
+ const word = match[1].toLowerCase();
310
+ const rest = match[2].trim();
311
+
312
+ if (ALL_KNOWN_TYPES.has(word)) {
313
+ const example = rest ? `${word} ${rest}` : word;
314
+ return makeDgmoError(
315
+ i + 1,
316
+ `Remove the colon — use '${example}' instead of '${trimmed}'. DGMO chart types don't use colons.`
317
+ );
318
+ }
319
+
320
+ // Check if it's a misspelling of a known type
321
+ const hint = suggest(word, [...ALL_KNOWN_TYPES]);
322
+ if (hint) {
323
+ return makeDgmoError(
324
+ i + 1,
325
+ `Unknown chart type: ${word}. ${hint} Also, DGMO chart types don't use colons.`
326
+ );
327
+ }
328
+
329
+ return null; // First line has colon but isn't a chart type — normal data
330
+ }
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * Detects when content has only the chart type line with no meaningful data lines.
336
+ */
337
+ function detectEmptyContent(content: string): DgmoError[] {
338
+ const lines = content.split('\n');
339
+ const nonEmpty = lines.filter(
340
+ (l) => l.trim() && !l.trim().startsWith('#') && !l.trim().startsWith('//')
341
+ );
342
+ if (nonEmpty.length <= 1) {
343
+ return [
344
+ makeDgmoError(1, 'No content after chart type declaration.', 'warning'),
345
+ ];
346
+ }
347
+ return [];
256
348
  }
@@ -9,14 +9,23 @@ export interface DgmoError {
9
9
  column?: number; // optional 1-based column
10
10
  message: string; // without "Line N:" prefix
11
11
  severity: DgmoSeverity;
12
+ /**
13
+ * Optional stable diagnostic code (e.g. 'E_ARROW_SUBSTRING_IN_LABEL').
14
+ * Additive; pre-existing diagnostics omit this field and existing
15
+ * substring-on-`.message` assertions keep working unchanged.
16
+ */
17
+ code?: string;
12
18
  }
13
19
 
14
20
  export function makeDgmoError(
15
21
  line: number,
16
22
  message: string,
17
- severity: DgmoSeverity = 'error'
23
+ severity: DgmoSeverity = 'error',
24
+ code?: string
18
25
  ): DgmoError {
19
- return { line, message, severity };
26
+ return code !== undefined
27
+ ? { line, message, severity, code }
28
+ : { line, message, severity };
20
29
  }
21
30
 
22
31
  export function formatDgmoError(err: DgmoError): string {
@@ -43,9 +52,7 @@ function levenshtein(a: string, b: string): number {
43
52
  for (let j = 1; j <= n; j++) {
44
53
  const tmp = dp[j];
45
54
  dp[j] =
46
- a[i - 1] === b[j - 1]
47
- ? prev
48
- : 1 + Math.min(prev, dp[j], dp[j - 1]);
55
+ a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
49
56
  prev = tmp;
50
57
  }
51
58
  }
@@ -57,7 +64,10 @@ function levenshtein(a: string, b: string): number {
57
64
  * Returns null if no good match is found.
58
65
  * Threshold: distance ≤ max(2, floor(input.length / 3))
59
66
  */
60
- export function suggest(input: string, candidates: readonly string[]): string | null {
67
+ export function suggest(
68
+ input: string,
69
+ candidates: readonly string[]
70
+ ): string | null {
61
71
  if (!input || candidates.length === 0) return null;
62
72
  const lower = input.toLowerCase();
63
73
  const threshold = Math.max(2, Math.floor(lower.length / 3));
package/src/echarts.ts CHANGED
@@ -1,7 +1,47 @@
1
- import * as echarts from 'echarts';
1
+ import * as echarts from 'echarts/core';
2
2
  import type { EChartsOption } from 'echarts';
3
+ import {
4
+ BarChart,
5
+ LineChart,
6
+ PieChart,
7
+ ScatterChart,
8
+ RadarChart,
9
+ SankeyChart,
10
+ GraphChart,
11
+ HeatmapChart,
12
+ FunnelChart,
13
+ } from 'echarts/charts';
14
+ import {
15
+ GridComponent,
16
+ TitleComponent,
17
+ TooltipComponent,
18
+ LegendComponent,
19
+ RadarComponent,
20
+ VisualMapComponent,
21
+ GraphicComponent,
22
+ } from 'echarts/components';
23
+ import { SVGRenderer } from 'echarts/renderers';
24
+
25
+ echarts.use([
26
+ BarChart,
27
+ LineChart,
28
+ PieChart,
29
+ ScatterChart,
30
+ RadarChart,
31
+ SankeyChart,
32
+ GraphChart,
33
+ HeatmapChart,
34
+ FunnelChart,
35
+ GridComponent,
36
+ TitleComponent,
37
+ TooltipComponent,
38
+ LegendComponent,
39
+ RadarComponent,
40
+ VisualMapComponent,
41
+ GraphicComponent,
42
+ SVGRenderer,
43
+ ]);
3
44
  import { FONT_FAMILY } from './fonts';
4
- import { injectBranding } from './branding';
5
45
  import { renderLegendSvg } from './utils/legend-svg';
6
46
  import type { LegendGroupData } from './utils/legend-svg';
7
47
  import {
@@ -2863,8 +2903,7 @@ const STANDARD_CHART_TYPES = new Set([
2863
2903
  export async function renderExtendedChartForExport(
2864
2904
  content: string,
2865
2905
  theme: 'light' | 'dark' | 'transparent',
2866
- palette?: PaletteColors,
2867
- options?: { branding?: boolean }
2906
+ palette?: PaletteColors
2868
2907
  ): Promise<string> {
2869
2908
  const isDark = theme === 'dark';
2870
2909
 
@@ -2965,12 +3004,6 @@ export async function renderExtendedChartForExport(
2965
3004
  );
2966
3005
  }
2967
3006
 
2968
- if (options?.branding !== false) {
2969
- const brandColor =
2970
- theme === 'transparent' ? '#888' : effectivePalette.textMuted;
2971
- result = injectBranding(result, brandColor);
2972
- }
2973
-
2974
3007
  return result;
2975
3008
  } finally {
2976
3009
  chart.dispose();
package/src/er/parser.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolveColorWithDiagnostic } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
+ import { validateLabelCharacters } from '../utils/arrows';
4
5
  import {
5
6
  measureIndent,
6
7
  extractColor,
@@ -108,12 +109,22 @@ function parseRelationship(
108
109
  const fromCard = parseCardSide(sym[2]);
109
110
  const toCard = parseCardSide(sym[3]);
110
111
  if (fromCard && toCard) {
112
+ const label = sym[5]?.trim();
113
+ // F17: run label through validator for defense in depth. The parent
114
+ // loop currently discards top-level relationships as warnings, so
115
+ // the label never reaches the AST — but if that changes, this keeps
116
+ // character-set validation in sync with the indented path.
117
+ if (label) {
118
+ validateLabelCharacters(label, lineNumber).forEach((d) =>
119
+ pushError(d.line, d.message)
120
+ );
121
+ }
111
122
  return {
112
123
  source: sym[1],
113
124
  target: sym[4],
114
125
  from: fromCard,
115
126
  to: toCard,
116
- label: sym[5]?.trim(),
127
+ label,
117
128
  };
118
129
  }
119
130
  }
@@ -321,6 +332,9 @@ export function parseERDiagram(
321
332
  // Indented lines = columns or relationships of current table
322
333
  if (indent > 0 && currentTable) {
323
334
  // Try indented relationship first: 1-* target or 1-label-* target
335
+ // ER chart-specific constraint: labels cannot contain `-` because
336
+ // INDENT_REL_RE uses `-{1,2}` as hard delimiters on both sides of the
337
+ // label. So `1-has-*` works but `1-has dashes-*` does not.
324
338
  const indentRel = trimmed.match(INDENT_REL_RE);
325
339
  if (indentRel) {
326
340
  const fromCard = parseCardSide(indentRel[1]);
@@ -328,11 +342,17 @@ export function parseERDiagram(
328
342
  if (fromCard && toCard) {
329
343
  const targetName = indentRel[4];
330
344
  getOrCreateTable(targetName, lineNumber);
345
+ const rawLabel = indentRel[2]?.trim();
346
+ if (rawLabel) {
347
+ result.diagnostics.push(
348
+ ...validateLabelCharacters(rawLabel, lineNumber)
349
+ );
350
+ }
331
351
  result.relationships.push({
332
352
  source: currentTable.id,
333
353
  target: tableId(targetName),
334
354
  cardinality: { from: fromCard, to: toCard },
335
- ...(indentRel[2]?.trim() && { label: indentRel[2].trim() }),
355
+ ...(rawLabel && { label: rawLabel }),
336
356
  lineNumber,
337
357
  });
338
358
  }
@@ -8,7 +8,7 @@ import { FONT_FAMILY } from '../fonts';
8
8
  import { getSeriesColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
11
- import { computeTimeTicks } from '../d3';
11
+ import { computeTimeTicks } from '../utils/time-ticks';
12
12
  import {
13
13
  LEGEND_HEIGHT,
14
14
  LEGEND_PILL_PAD,
@@ -36,7 +36,7 @@ import {
36
36
  TITLE_Y,
37
37
  } from '../utils/title-constants';
38
38
  import type { PaletteColors } from '../palettes';
39
- import type { D3ExportDimensions } from '../d3';
39
+ import type { D3ExportDimensions } from '../utils/d3-types';
40
40
  import type {
41
41
  ResolvedSchedule,
42
42
  ResolvedTask,
@@ -2,6 +2,7 @@ import { resolveColorWithDiagnostic } from '../colors';
2
2
  import type { DgmoError } from '../diagnostics';
3
3
  import type { PaletteColors } from '../palettes';
4
4
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
5
+ import { parseInArrowLabel, matchColorParens } from '../utils/arrows';
5
6
  import {
6
7
  measureIndent,
7
8
  extractColor,
@@ -87,15 +88,17 @@ function parseNodeRef(text: string, palette?: PaletteColors): NodeRef | null {
87
88
 
88
89
  /**
89
90
  * Split a line into segments around arrow tokens.
90
- * Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`
91
+ * Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`, and long-dash
92
+ * variants like `-->`, `--->`, `--foo--->` (TD-9 longest-match: the arrow
93
+ * token is the maximal run of `-+>`).
91
94
  *
92
95
  * Returns alternating: [nodeText, arrowText, nodeText, arrowText, nodeText, ...]
93
- * Where arrowText is the full arrow token like `-yes->` or `->`.
96
+ * Where arrowText is the synthesized full arrow token like `-yes->` or `->`
97
+ * (with visual dash-run length collapsed to the minimal `-...->` form —
98
+ * edge styling is not yet differentiated by arrow length).
94
99
  */
95
100
  function splitArrows(line: string): string[] {
96
101
  const segments: string[] = [];
97
- let lastIndex = 0;
98
- // Simpler approach: find all `->` positions, then determine if there's a label prefix
99
102
  const arrowPositions: {
100
103
  start: number;
101
104
  end: number;
@@ -103,60 +106,84 @@ function splitArrows(line: string): string[] {
103
106
  color?: string;
104
107
  }[] = [];
105
108
 
106
- // Find all -> occurrences
109
+ // Find all arrow tokens. A token is a maximal run of `-+>` (one-or-more
110
+ // dashes followed by `>`). We scan for `->` and then expand leftward across
111
+ // adjacent dashes to absorb longer forms. `scanFloor` marks the lower
112
+ // bound for the next opening-dash search so an arrow's opening cannot
113
+ // reach back into the territory of a previously consumed arrow.
107
114
  let searchFrom = 0;
115
+ let scanFloor = 0;
108
116
  while (searchFrom < line.length) {
109
117
  const idx = line.indexOf('->', searchFrom);
110
118
  if (idx === -1) break;
111
119
 
112
- // Look backwards from idx to find the start of the arrow (the `-` that starts the label)
113
- let arrowStart = idx;
120
+ // TD-9: absorb the full arrow run leftward from idx, but not past the
121
+ // scanFloor (which is right after the previous arrow).
122
+ let runStart = idx;
123
+ while (runStart > scanFloor && line[runStart - 1] === '-') runStart--;
124
+ const arrowEnd = idx + 2; // position after `>`
125
+
126
+ // Look for an opening dash run before the arrow. The opening is the
127
+ // LEFTMOST `-` in the region `[scanFloor, runStart)` that is preceded by
128
+ // whitespace or start-of-line. Any dashes to its right up to the first
129
+ // non-dash character are part of the opening run; content after that is
130
+ // the label; the full arrow token runs from opening through `>`.
131
+ let arrowStart: number;
114
132
  let label: string | undefined;
115
133
  let color: string | undefined;
116
134
 
117
- // Check if there's content between a preceding `-` and this `->` (e.g., `-yes->`)
118
- // Walk backwards from idx-1 to find another `-` that could be the arrow start
119
- if (idx > 0 && line[idx - 1] !== ' ' && line[idx - 1] !== '\t') {
120
- // There might be label/color content attached: e.g. `-yes->` or `-(blue)->`
121
- // The arrow token starts with `-` followed by optional label, optional (color), then `->`
122
- // We need to find the opening `-` before any label text
123
- // Scan backwards to find a `-` preceded by whitespace or start-of-line
124
- let scanBack = idx - 1;
125
- while (scanBack > 0 && line[scanBack] !== '-') {
126
- scanBack--;
135
+ let openingStart = -1;
136
+ for (let i = scanFloor; i < runStart; i++) {
137
+ if (line[i] !== '-') continue;
138
+ const prevIsWsOrFloor =
139
+ i === 0 || i === scanFloor || /\s/.test(line[i - 1]);
140
+ if (prevIsWsOrFloor) {
141
+ openingStart = i;
142
+ break;
127
143
  }
128
- // Check if this `-` could be the start of the arrow
129
- if (
130
- line[scanBack] === '-' &&
131
- (scanBack === 0 || /\s/.test(line[scanBack - 1]))
132
- ) {
133
- // Content between opening `-` and `->` (strip trailing `-` that is part of `->`)
134
- let arrowContent = line.substring(scanBack + 1, idx);
135
- if (arrowContent.endsWith('-'))
136
- arrowContent = arrowContent.slice(0, -1);
137
- // Parse label and color from arrow content
138
- const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
139
- if (colorMatch) {
140
- color = colorMatch[1].trim();
141
- const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
142
- if (labelPart) label = labelPart;
143
- } else {
144
- const labelPart = arrowContent.trim();
145
- if (labelPart) label = labelPart;
146
- }
147
- arrowStart = scanBack;
144
+ }
145
+
146
+ if (openingStart !== -1) {
147
+ // End of opening run: consume consecutive dashes after openingStart.
148
+ let openingEnd = openingStart;
149
+ while (openingEnd < runStart && line[openingEnd] === '-') openingEnd++;
150
+
151
+ // Label content = everything between opening run and the arrow run.
152
+ const arrowContent = line.substring(openingEnd, runStart);
153
+ const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
154
+ if (colorMatch) {
155
+ color = colorMatch[1].trim();
156
+ const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
157
+ if (labelPart) label = labelPart;
158
+ } else {
159
+ const labelPart = arrowContent.trim();
160
+ if (labelPart) label = labelPart;
148
161
  }
162
+ arrowStart = openingStart;
163
+ } else {
164
+ // No opening dash run found. All absorbed leftward dashes belong to
165
+ // the arrow token itself (e.g. `A --> B` → arrow is `-->`, no label).
166
+ arrowStart = runStart;
149
167
  }
150
168
 
151
- arrowPositions.push({ start: arrowStart, end: idx + 2, label, color });
152
- searchFrom = idx + 2;
169
+ arrowPositions.push({ start: arrowStart, end: arrowEnd, label, color });
170
+ searchFrom = arrowEnd;
171
+ scanFloor = arrowEnd;
153
172
  }
154
173
 
155
174
  if (arrowPositions.length === 0) {
156
175
  return [line];
157
176
  }
158
177
 
159
- // Build segments
178
+ // Build segments.
179
+ //
180
+ // NOTE: the synthesized arrow token is always the short form (`->`,
181
+ // `-label->`, `-(color)->`). The actual dash run-length (`-->`, `--->`,
182
+ // `---->`) seen in the source is collapsed here. If we ever add
183
+ // dash-length-sensitive edge styling (e.g. Mermaid-style "long arrow"
184
+ // emphasis), thread `arrow.end - arrow.start - label?.length - color?.length`
185
+ // through to ArrowInfo so downstream renderers can honor it.
186
+ let lastIndex = 0;
160
187
  for (let i = 0; i < arrowPositions.length; i++) {
161
188
  const arrow = arrowPositions[i];
162
189
  const beforeText = line.substring(lastIndex, arrow.start).trim();
@@ -193,22 +220,32 @@ function parseArrowToken(
193
220
  diagnostics: DgmoError[]
194
221
  ): ArrowInfo {
195
222
  if (token === '->') return {};
196
- // Color-only: -(color)->
197
- const colorOnly = token.match(/^-\(([^)]+)\)->$/);
198
- if (colorOnly) {
199
- return {
200
- color: resolveColorWithDiagnostic(
201
- colorOnly[1].trim(),
202
- lineNumber,
203
- diagnostics,
204
- palette
205
- ),
206
- };
223
+ // TD-11: `-(X)->` is a color if and only if `X` is one of the 11 recognized
224
+ // palette color names. Otherwise the entire `(X)` becomes the label.
225
+ // Delegate the recognition rule to the shared `matchColorParens` helper.
226
+ const bareParen = token.match(/^-(\([A-Za-z]+\))->$/);
227
+ if (bareParen) {
228
+ const colorName = matchColorParens(bareParen[1]);
229
+ if (colorName) {
230
+ return {
231
+ color: resolveColorWithDiagnostic(
232
+ colorName,
233
+ lineNumber,
234
+ diagnostics,
235
+ palette
236
+ ),
237
+ };
238
+ }
239
+ // Unrecognized color name → whole `(X)` is the label (fall through).
207
240
  }
208
241
  // -label(color)-> or -label->
209
242
  const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
210
243
  if (m) {
211
- const label = m[1]?.trim() || undefined;
244
+ const rawLabel = m[1] ?? '';
245
+ // Route label through TD-13/TD-14 validator.
246
+ const labelResult = parseInArrowLabel(rawLabel, lineNumber);
247
+ diagnostics.push(...labelResult.diagnostics);
248
+ const label = labelResult.label;
212
249
  let color = m[2]
213
250
  ? resolveColorWithDiagnostic(
214
251
  m[2].trim(),
@@ -2,6 +2,7 @@ import { resolveColorWithDiagnostic } from '../colors';
2
2
  import type { DgmoError } from '../diagnostics';
3
3
  import type { PaletteColors } from '../palettes';
4
4
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
5
+ import { parseInArrowLabel, matchColorParens } from '../utils/arrows';
5
6
  import {
6
7
  measureIndent,
7
8
  extractColor,
@@ -31,6 +32,8 @@ const GROUP_BRACKET_RE = /^\[([^\]]+)\](?:\(([^)]+)\))?\s*$/;
31
32
  * Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`
32
33
  */
33
34
  function splitArrows(line: string): string[] {
35
+ // Mirrors flowchart-parser.ts splitArrows. TD-9 longest-match: arrow token
36
+ // is the maximal run of `-+>`. See that file for the full algorithm rationale.
34
37
  const segments: string[] = [];
35
38
  const arrowPositions: {
36
39
  start: number;
@@ -40,41 +43,52 @@ function splitArrows(line: string): string[] {
40
43
  }[] = [];
41
44
 
42
45
  let searchFrom = 0;
46
+ let scanFloor = 0;
43
47
  while (searchFrom < line.length) {
44
48
  const idx = line.indexOf('->', searchFrom);
45
49
  if (idx === -1) break;
46
50
 
47
- let arrowStart = idx;
51
+ let runStart = idx;
52
+ while (runStart > scanFloor && line[runStart - 1] === '-') runStart--;
53
+ const arrowEnd = idx + 2;
54
+
55
+ let arrowStart: number;
48
56
  let label: string | undefined;
49
57
  let color: string | undefined;
50
58
 
51
- if (idx > 0 && line[idx - 1] !== ' ' && line[idx - 1] !== '\t') {
52
- let scanBack = idx - 1;
53
- while (scanBack > 0 && line[scanBack] !== '-') {
54
- scanBack--;
59
+ let openingStart = -1;
60
+ for (let i = scanFloor; i < runStart; i++) {
61
+ if (line[i] !== '-') continue;
62
+ const prevIsWsOrFloor =
63
+ i === 0 || i === scanFloor || /\s/.test(line[i - 1]);
64
+ if (prevIsWsOrFloor) {
65
+ openingStart = i;
66
+ break;
55
67
  }
56
- if (
57
- line[scanBack] === '-' &&
58
- (scanBack === 0 || /\s/.test(line[scanBack - 1]))
59
- ) {
60
- let arrowContent = line.substring(scanBack + 1, idx);
61
- if (arrowContent.endsWith('-'))
62
- arrowContent = arrowContent.slice(0, -1);
63
- const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
64
- if (colorMatch) {
65
- color = colorMatch[1].trim();
66
- const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
67
- if (labelPart) label = labelPart;
68
- } else {
69
- const labelPart = arrowContent.trim();
70
- if (labelPart) label = labelPart;
71
- }
72
- arrowStart = scanBack;
68
+ }
69
+
70
+ if (openingStart !== -1) {
71
+ let openingEnd = openingStart;
72
+ while (openingEnd < runStart && line[openingEnd] === '-') openingEnd++;
73
+
74
+ const arrowContent = line.substring(openingEnd, runStart);
75
+ const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
76
+ if (colorMatch) {
77
+ color = colorMatch[1].trim();
78
+ const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
79
+ if (labelPart) label = labelPart;
80
+ } else {
81
+ const labelPart = arrowContent.trim();
82
+ if (labelPart) label = labelPart;
73
83
  }
84
+ arrowStart = openingStart;
85
+ } else {
86
+ arrowStart = runStart;
74
87
  }
75
88
 
76
- arrowPositions.push({ start: arrowStart, end: idx + 2, label, color });
77
- searchFrom = idx + 2;
89
+ arrowPositions.push({ start: arrowStart, end: arrowEnd, label, color });
90
+ searchFrom = arrowEnd;
91
+ scanFloor = arrowEnd;
78
92
  }
79
93
 
80
94
  if (arrowPositions.length === 0) return [line];
@@ -111,19 +125,30 @@ function parseArrowToken(
111
125
  diagnostics: DgmoError[]
112
126
  ): ArrowInfo {
113
127
  if (token === '->') return {};
114
- const colorOnly = token.match(/^-\(([^)]+)\)->$/);
115
- if (colorOnly)
116
- return {
117
- color: resolveColorWithDiagnostic(
118
- colorOnly[1].trim(),
119
- lineNumber,
120
- diagnostics,
121
- palette
122
- ),
123
- };
128
+ // TD-11: `-(X)->` is a color if and only if X is a recognized palette
129
+ // color; otherwise the whole `(X)` becomes the label. Delegate recognition
130
+ // to the shared `matchColorParens` helper.
131
+ const bareParen = token.match(/^-(\([A-Za-z]+\))->$/);
132
+ if (bareParen) {
133
+ const colorName = matchColorParens(bareParen[1]);
134
+ if (colorName) {
135
+ return {
136
+ color: resolveColorWithDiagnostic(
137
+ colorName,
138
+ lineNumber,
139
+ diagnostics,
140
+ palette
141
+ ),
142
+ };
143
+ }
144
+ // fall through — whole `(X)` becomes label
145
+ }
124
146
  const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
125
147
  if (m) {
126
- const label = m[1]?.trim() || undefined;
148
+ const rawLabel = m[1] ?? '';
149
+ const labelResult = parseInArrowLabel(rawLabel, lineNumber);
150
+ diagnostics.push(...labelResult.diagnostics);
151
+ const label = labelResult.label;
127
152
  const color = m[2]
128
153
  ? resolveColorWithDiagnostic(
129
154
  m[2].trim(),