@graphty/graph-io 0.3.41 → 0.3.42

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.
Files changed (98) hide show
  1. package/README.md +2 -0
  2. package/dist/chunks/{children-CL3Cy0ez.js → children-CA4coBi0.js} +36 -27
  3. package/dist/chunks/children-CA4coBi0.js.map +1 -0
  4. package/dist/chunks/{choose-CwFQamH9.js → choose-DvpbdRuz.js} +2 -2
  5. package/dist/chunks/{choose-CwFQamH9.js.map → choose-DvpbdRuz.js.map} +1 -1
  6. package/dist/chunks/{constants-Cf4Nn6tw.js → constants-fT3E8JYY.js} +2 -2
  7. package/dist/chunks/{constants-Cf4Nn6tw.js.map → constants-fT3E8JYY.js.map} +1 -1
  8. package/dist/chunks/{escape-DBb8zuNE.js → escape-B62ZGM_a.js} +2 -2
  9. package/dist/chunks/{escape-DBb8zuNE.js.map → escape-B62ZGM_a.js.map} +1 -1
  10. package/dist/chunks/{export-DD3Lvw-F.js → export-rKLeGQMo.js} +232 -128
  11. package/dist/chunks/export-rKLeGQMo.js.map +1 -0
  12. package/dist/chunks/{exporter-CS35R6Fg.js → exporter-8hLNemFk.js} +4 -4
  13. package/dist/chunks/{exporter-CS35R6Fg.js.map → exporter-8hLNemFk.js.map} +1 -1
  14. package/dist/chunks/{exporter-D8r1N2dU.js → exporter-BWwEuOPT.js} +5 -5
  15. package/dist/chunks/{exporter-D8r1N2dU.js.map → exporter-BWwEuOPT.js.map} +1 -1
  16. package/dist/chunks/{exporter-BqtmlxNr.js → exporter-CpRyHZ3H.js} +34 -21
  17. package/dist/chunks/exporter-CpRyHZ3H.js.map +1 -0
  18. package/dist/chunks/{exporter-NMplSnYn.js → exporter-yLxKBvLE.js} +3 -3
  19. package/dist/chunks/{exporter-NMplSnYn.js.map → exporter-yLxKBvLE.js.map} +1 -1
  20. package/dist/chunks/{importer-S-abxJHn.js → importer-BbNb7eNg.js} +111 -71
  21. package/dist/chunks/importer-BbNb7eNg.js.map +1 -0
  22. package/dist/chunks/{importer-BdWHmlmG.js → importer-C98dpSH4.js} +5 -5
  23. package/dist/chunks/{importer-BdWHmlmG.js.map → importer-C98dpSH4.js.map} +1 -1
  24. package/dist/chunks/{importer-r29cWnEf.js → importer-D794XUTe.js} +3 -3
  25. package/dist/chunks/{importer-r29cWnEf.js.map → importer-D794XUTe.js.map} +1 -1
  26. package/dist/chunks/{importer-BfbzWk6k.js → importer-Dz7tI8Sa.js} +5 -5
  27. package/dist/chunks/{importer-BfbzWk6k.js.map → importer-Dz7tI8Sa.js.map} +1 -1
  28. package/dist/chunks/{importer-DviEsHy4.js → importer-Ht7LNg8L.js} +4 -4
  29. package/dist/chunks/{importer-DviEsHy4.js.map → importer-Ht7LNg8L.js.map} +1 -1
  30. package/dist/chunks/{importer-BKC7AmCA.js → importer-WrGRZb0N.js} +6 -6
  31. package/dist/chunks/{importer-BKC7AmCA.js.map → importer-WrGRZb0N.js.map} +1 -1
  32. package/dist/chunks/{importer-BmaZcskq.js → importer-xZ-3suY1.js} +5 -5
  33. package/dist/chunks/{importer-BmaZcskq.js.map → importer-xZ-3suY1.js.map} +1 -1
  34. package/dist/chunks/{json-elements-DdF-etPT.js → json-elements-DKSqpNW1.js} +3 -3
  35. package/dist/chunks/{json-elements-DdF-etPT.js.map → json-elements-DKSqpNW1.js.map} +1 -1
  36. package/dist/chunks/{ontology-export-BC0zyPhj.js → ontology-export-CtRXjg_i.js} +2 -2
  37. package/dist/chunks/{ontology-export-BC0zyPhj.js.map → ontology-export-CtRXjg_i.js.map} +1 -1
  38. package/dist/chunks/{records-Uc3LkABx.js → records-Cu4bxYOm.js} +56 -26
  39. package/dist/chunks/records-Cu4bxYOm.js.map +1 -0
  40. package/dist/chunks/{text-Bh9SaAob.js → text-DHOJXnED.js} +2 -2
  41. package/dist/chunks/{text-Bh9SaAob.js.map → text-DHOJXnED.js.map} +1 -1
  42. package/dist/csv.js +260 -196
  43. package/dist/csv.js.map +1 -1
  44. package/dist/cx.js +1 -1
  45. package/dist/cx2.js +2 -2
  46. package/dist/cys.js +1 -1
  47. package/dist/dot.js +1 -1
  48. package/dist/gexf.js +2 -2
  49. package/dist/gml.js +3 -3
  50. package/dist/graph-io.js +27 -27
  51. package/dist/graphml.js +2 -2
  52. package/dist/json.js +1 -1
  53. package/dist/neo4j.js +3 -3
  54. package/dist/obo.js +1 -1
  55. package/dist/pajek.js +1 -1
  56. package/dist/src/children.d.ts.map +1 -1
  57. package/dist/src/children.js +50 -27
  58. package/dist/src/children.js.map +1 -1
  59. package/dist/src/common/attributes.d.ts.map +1 -1
  60. package/dist/src/common/attributes.js +52 -33
  61. package/dist/src/common/attributes.js.map +1 -1
  62. package/dist/src/common/input.d.ts.map +1 -1
  63. package/dist/src/common/input.js +178 -82
  64. package/dist/src/common/input.js.map +1 -1
  65. package/dist/src/common/lists.js +30 -15
  66. package/dist/src/common/lists.js.map +1 -1
  67. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  68. package/dist/src/formats/csv/exporter.js +211 -121
  69. package/dist/src/formats/csv/exporter.js.map +1 -1
  70. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  71. package/dist/src/formats/csv/importer.js +113 -74
  72. package/dist/src/formats/csv/importer.js.map +1 -1
  73. package/dist/src/formats/csv/records.d.ts.map +1 -1
  74. package/dist/src/formats/csv/records.js +60 -26
  75. package/dist/src/formats/csv/records.js.map +1 -1
  76. package/dist/src/formats/dot/tokenizer.d.ts +39 -0
  77. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -1
  78. package/dist/src/formats/dot/tokenizer.js +112 -66
  79. package/dist/src/formats/dot/tokenizer.js.map +1 -1
  80. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  81. package/dist/src/formats/json/dialect.js +54 -20
  82. package/dist/src/formats/json/dialect.js.map +1 -1
  83. package/dist/xgmml.js +2 -2
  84. package/package.json +2 -2
  85. package/src/children.ts +54 -27
  86. package/src/common/attributes.ts +59 -30
  87. package/src/common/input.ts +223 -95
  88. package/src/common/lists.ts +29 -15
  89. package/src/formats/csv/exporter.ts +274 -136
  90. package/src/formats/csv/importer.ts +138 -78
  91. package/src/formats/csv/records.ts +71 -25
  92. package/src/formats/dot/tokenizer.ts +118 -68
  93. package/src/formats/json/dialect.ts +61 -22
  94. package/dist/chunks/children-CL3Cy0ez.js.map +0 -1
  95. package/dist/chunks/export-DD3Lvw-F.js.map +0 -1
  96. package/dist/chunks/exporter-BqtmlxNr.js.map +0 -1
  97. package/dist/chunks/importer-S-abxJHn.js.map +0 -1
  98. package/dist/chunks/records-Uc3LkABx.js.map +0 -1
@@ -385,6 +385,13 @@ interface AdjacencyPlan {
385
385
  }
386
386
 
387
387
  /** Everything one import call shares between its tables. */
388
+ /** A row read before the plan exists, kept to be pushed once it does. */
389
+ interface PendingRow {
390
+ readonly row: string[];
391
+ readonly quoted: readonly boolean[];
392
+ readonly line: number;
393
+ }
394
+
388
395
  interface ImportState {
389
396
  readonly sink: GraphSink;
390
397
  readonly report: ImportReportBuilder;
@@ -455,6 +462,43 @@ function isPadded(text: string, quoted: boolean | undefined): boolean {
455
462
  */
456
463
  function resolveCsvOptions(options: (CsvImportOptions & CommonImportOptions) | undefined): ResolvedCsvOptions {
457
464
  const o: CsvImportOptions = options ?? {};
465
+ checkDialectOptions(o);
466
+ for (const name of ["sourceColumn", "targetColumn", "idColumn", "labelColumn"] as const) {
467
+ checkColumnRef(name, o[name]);
468
+ }
469
+ if (o.typeColumn !== null) {
470
+ checkColumnRef("typeColumn", o.typeColumn);
471
+ }
472
+ if (o.table === "adjacency") {
473
+ checkAdjacencyOptions(o);
474
+ }
475
+ if (o.rowNumberIds !== undefined && typeof o.rowNumberIds !== "boolean") {
476
+ throw new GraphFormatError("E_UNSUPPORTED", "option rowNumberIds: expected a boolean", {
477
+ option: "rowNumberIds",
478
+ found: o.rowNumberIds,
479
+ });
480
+ }
481
+ return {
482
+ delimiter: o.delimiter ?? null,
483
+ decimalComma: o.decimal === ",",
484
+ header: o.header ?? "auto",
485
+ table: o.table ?? "auto",
486
+ sourceColumn: o.sourceColumn ?? null,
487
+ targetColumn: o.targetColumn ?? null,
488
+ typeColumn: o.typeColumn,
489
+ idColumn: o.idColumn ?? null,
490
+ labelColumn: o.labelColumn ?? null,
491
+ nodes: nodesInput(o.nodes),
492
+ rowNumberIds: o.rowNumberIds ?? false,
493
+ explicitDirected: typeof options?.defaultDirected === "boolean" ? options.defaultDirected : null,
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Check the dialect and table options: delimiter, decimal, header and table.
499
+ * @param o - the caller's CSV options
500
+ */
501
+ function checkDialectOptions(o: CsvImportOptions): void {
458
502
  if (o.delimiter !== undefined && (typeof o.delimiter !== "string" || o.delimiter.length === 0)) {
459
503
  throw new GraphFormatError("E_UNSUPPORTED", "option delimiter: expected a non-empty string", {
460
504
  option: "delimiter",
@@ -489,48 +533,27 @@ function resolveCsvOptions(options: (CsvImportOptions & CommonImportOptions) | u
489
533
  found: o.table,
490
534
  });
491
535
  }
492
- for (const name of ["sourceColumn", "targetColumn", "idColumn", "labelColumn"] as const) {
493
- checkColumnRef(name, o[name]);
494
- }
495
- if (o.typeColumn !== null) {
496
- checkColumnRef("typeColumn", o.typeColumn);
497
- }
498
- if (o.table === "adjacency") {
499
- for (const name of ["sourceColumn", "targetColumn", "typeColumn", "idColumn", "labelColumn"] as const) {
500
- if (o[name] !== undefined) {
501
- throw new GraphFormatError("E_UNSUPPORTED", `option ${name}: an adjacency table has no columns`, {
502
- option: name,
503
- found: o[name],
504
- });
505
- }
506
- }
507
- if (o.rowNumberIds === true) {
508
- throw new GraphFormatError("E_UNSUPPORTED", "option rowNumberIds: an adjacency table names its nodes", {
509
- option: "rowNumberIds",
510
- found: o.rowNumberIds,
536
+ }
537
+
538
+ /**
539
+ * Check the options an adjacency table has no use for: column options and rowNumberIds.
540
+ * @param o - the caller's CSV options
541
+ */
542
+ function checkAdjacencyOptions(o: CsvImportOptions): void {
543
+ for (const name of ["sourceColumn", "targetColumn", "typeColumn", "idColumn", "labelColumn"] as const) {
544
+ if (o[name] !== undefined) {
545
+ throw new GraphFormatError("E_UNSUPPORTED", `option ${name}: an adjacency table has no columns`, {
546
+ option: name,
547
+ found: o[name],
511
548
  });
512
549
  }
513
550
  }
514
- if (o.rowNumberIds !== undefined && typeof o.rowNumberIds !== "boolean") {
515
- throw new GraphFormatError("E_UNSUPPORTED", "option rowNumberIds: expected a boolean", {
551
+ if (o.rowNumberIds === true) {
552
+ throw new GraphFormatError("E_UNSUPPORTED", "option rowNumberIds: an adjacency table names its nodes", {
516
553
  option: "rowNumberIds",
517
554
  found: o.rowNumberIds,
518
555
  });
519
556
  }
520
- return {
521
- delimiter: o.delimiter ?? null,
522
- decimalComma: o.decimal === ",",
523
- header: o.header ?? "auto",
524
- table: o.table ?? "auto",
525
- sourceColumn: o.sourceColumn ?? null,
526
- targetColumn: o.targetColumn ?? null,
527
- typeColumn: o.typeColumn,
528
- idColumn: o.idColumn ?? null,
529
- labelColumn: o.labelColumn ?? null,
530
- nodes: nodesInput(o.nodes),
531
- rowNumberIds: o.rowNumberIds ?? false,
532
- explicitDirected: typeof options?.defaultDirected === "boolean" ? options.defaultDirected : null,
533
- };
534
557
  }
535
558
 
536
559
  /**
@@ -823,43 +846,12 @@ class TableReader {
823
846
  : first.value;
824
847
  const firstLine = this.reader.line;
825
848
  const firstQuoted = this.reader.quoted.slice(0, firstRow.length);
826
- const pending: { row: string[]; quoted: readonly boolean[]; line: number }[] = [];
827
- let header: boolean;
828
- // an adjacency table has no header unless the caller says so: its rows vary in width; a
829
- // table numbered by rowNumberIds has one, since a headerless table takes its ids from column 0
830
- let mode = this.state.csv.header;
831
- if (mode === "auto" && this.kind === "adjacency") {
832
- mode = false;
833
- } else if (mode === "auto" && this.rowNumbers) {
834
- mode = true;
835
- }
836
- if (mode === "auto") {
837
- const second = await iterator.next();
838
- const secondRow: string[] | null = second.done ? null : second.value;
839
- const secondLine = this.reader.line;
840
- const secondQuoted = this.reader.quoted.slice(0, secondRow?.length ?? 0);
841
- // a column option naming a cell of the first row makes that row the header
842
- header = looksLikeHeader(firstRow, secondRow) || this.namesExplicitColumn(firstRow);
843
- if (!header) {
844
- pending.push({ row: firstRow, quoted: firstQuoted, line: firstLine });
845
- }
846
- if (secondRow !== null) {
847
- pending.push({ row: secondRow, quoted: secondQuoted, line: secondLine });
848
- }
849
- } else {
850
- header = mode;
851
- if (!header) {
852
- pending.push({ row: firstRow, quoted: firstQuoted, line: firstLine });
853
- }
854
- }
855
- if (this.rowNumbers && !header) {
856
- report.warning(
857
- "unsupported",
858
- OPTION_IGNORED_CODE,
859
- "option rowNumberIds ignored: a headerless node table takes its ids from column 0",
860
- { line: firstLine, element: "rowNumberIds" },
861
- );
862
- }
849
+ const pending: PendingRow[] = [];
850
+ const header = await this.decideHeader(
851
+ iterator,
852
+ { row: firstRow, quoted: firstQuoted, line: firstLine },
853
+ pending,
854
+ );
863
855
  const names = header ? uniqueNames(headerNames(firstRow), report, firstLine) : positionalNames(firstRow.length);
864
856
  const last = firstRow.length - 1;
865
857
  const optionalLast = header && last > 0 && isUnset(firstRow[last], firstQuoted[last]);
@@ -889,6 +881,80 @@ class TableReader {
889
881
  for (const { row, quoted, line } of pending) {
890
882
  this.processRow(row, quoted, line);
891
883
  }
884
+ await this.readRemainingRows(iterator);
885
+ if (this.dataRows === 0 && header) {
886
+ report.warning("missing-value", NO_DATA_ROWS_CODE, "the table has a header and no data rows", {
887
+ line: firstLine,
888
+ });
889
+ }
890
+ this.reportReaderFindings();
891
+ }
892
+
893
+ /**
894
+ * Decide whether the first row is the header, reading the second row when the header option is
895
+ * "auto", and queue the rows read so far that are data.
896
+ * @param iterator - the record iterator, past the first row
897
+ * @param first - the first row
898
+ * @param pending - receives the data rows read so far, in order
899
+ * @returns whether the first row is the header
900
+ */
901
+ private async decideHeader(
902
+ iterator: AsyncGenerator<string[], void, undefined>,
903
+ first: PendingRow,
904
+ pending: PendingRow[],
905
+ ): Promise<boolean> {
906
+ const mode = this.headerMode();
907
+ let second: PendingRow | null = null;
908
+ let header: boolean;
909
+ if (mode === "auto") {
910
+ const next = await iterator.next();
911
+ second = next.done
912
+ ? null
913
+ : { row: next.value, quoted: this.reader.quoted.slice(0, next.value.length), line: this.reader.line };
914
+ // a column option naming a cell of the first row makes that row the header
915
+ header = looksLikeHeader(first.row, second?.row ?? null) || this.namesExplicitColumn(first.row);
916
+ } else {
917
+ header = mode;
918
+ }
919
+ if (!header) {
920
+ pending.push(first);
921
+ }
922
+ if (second !== null) {
923
+ pending.push(second);
924
+ }
925
+ if (this.rowNumbers && !header) {
926
+ this.state.report.warning(
927
+ "unsupported",
928
+ OPTION_IGNORED_CODE,
929
+ "option rowNumberIds ignored: a headerless node table takes its ids from column 0",
930
+ { line: first.line, element: "rowNumberIds" },
931
+ );
932
+ }
933
+ return header;
934
+ }
935
+
936
+ /**
937
+ * The header option as this table reads it: an adjacency table has no header unless the caller
938
+ * says so (its rows vary in width); a table numbered by rowNumberIds has one, since a headerless
939
+ * table takes its ids from column 0.
940
+ * @returns true, false or "auto"
941
+ */
942
+ private headerMode(): boolean | "auto" {
943
+ const mode = this.state.csv.header;
944
+ if (mode === "auto" && this.kind === "adjacency") {
945
+ return false;
946
+ }
947
+ if (mode === "auto" && this.rowNumbers) {
948
+ return true;
949
+ }
950
+ return mode;
951
+ }
952
+
953
+ /**
954
+ * Push every row after the header and the rows read to decide it, then finish the column writers.
955
+ * @param iterator - the record iterator
956
+ */
957
+ private async readRemainingRows(iterator: AsyncGenerator<string[], void, undefined>): Promise<void> {
892
958
  const { signal } = this.state.common;
893
959
  let sinceCheck = 0;
894
960
  for (;;) {
@@ -905,12 +971,6 @@ class TableReader {
905
971
  for (const writer of this.writers) {
906
972
  writer?.finish();
907
973
  }
908
- if (this.dataRows === 0 && header) {
909
- report.warning("missing-value", NO_DATA_ROWS_CODE, "the table has a header and no data rows", {
910
- line: firstLine,
911
- });
912
- }
913
- this.reportReaderFindings();
914
974
  }
915
975
 
916
976
  /**
@@ -586,14 +586,8 @@ export class RecordReader implements AsyncIterable<number> {
586
586
  }
587
587
  const pieces: string[] = [];
588
588
  let length = 0;
589
- let breaks = 0;
590
- let lastWasCr = false;
591
589
  let sniffed = false;
592
- // the leading comment lines are left out of the sniff, so they do not count toward its rows
593
- const commentCodes = (this.syntax.comments ?? []).map((c) => c.codePointAt(0));
594
- let leading = commentCodes.length > 0;
595
- let lineStart = true;
596
- let inComment = false;
590
+ const preview = new PreviewBreaks((this.syntax.comments ?? []).map((c) => c.codePointAt(0)));
597
591
  for await (const chunk of source) {
598
592
  if (sniffed) {
599
593
  yield chunk;
@@ -601,24 +595,8 @@ export class RecordReader implements AsyncIterable<number> {
601
595
  }
602
596
  pieces.push(chunk);
603
597
  length += chunk.length;
604
- for (let i = 0; i < chunk.length && breaks < PREVIEW_ROWS; i++) {
605
- const c = chunk.charCodeAt(i);
606
- if (c === LF || c === CR) {
607
- if (!(c === LF && lastWasCr) && !inComment) {
608
- breaks++;
609
- }
610
- inComment = false;
611
- lineStart = true;
612
- } else {
613
- if (lineStart && leading) {
614
- inComment = commentCodes.includes(c);
615
- leading = inComment;
616
- }
617
- lineStart = false;
618
- }
619
- lastWasCr = c === CR;
620
- }
621
- if (breaks < PREVIEW_ROWS && length < PREVIEW_CHARS) {
598
+ preview.scan(chunk);
599
+ if (preview.breaks < PREVIEW_ROWS && length < PREVIEW_CHARS) {
622
600
  continue;
623
601
  }
624
602
  const text = pieces.join("");
@@ -668,6 +646,74 @@ export class RecordReader implements AsyncIterable<number> {
668
646
  }
669
647
  }
670
648
 
649
+ /**
650
+ * Counts the line breaks of the text buffered for the delimiter sniff, up to PREVIEW_ROWS. The
651
+ * leading comment lines are left out of the sniff, so they do not count toward its rows.
652
+ */
653
+ class PreviewBreaks {
654
+ /** The line breaks counted so far (at most PREVIEW_ROWS). */
655
+ breaks = 0;
656
+
657
+ private readonly commentCodes: readonly (number | undefined)[];
658
+
659
+ private lastWasCr = false;
660
+
661
+ private leading: boolean;
662
+
663
+ private lineStart = true;
664
+
665
+ private inComment = false;
666
+
667
+ /**
668
+ * Start counting at the start of the input.
669
+ * @param commentCodes - the code points of the comment characters
670
+ */
671
+ constructor(commentCodes: readonly (number | undefined)[]) {
672
+ this.commentCodes = commentCodes;
673
+ this.leading = commentCodes.length > 0;
674
+ }
675
+
676
+ /**
677
+ * Count the line breaks of the next chunk, stopping at PREVIEW_ROWS.
678
+ * @param chunk - the chunk
679
+ */
680
+ scan(chunk: string): void {
681
+ for (let i = 0; i < chunk.length && this.breaks < PREVIEW_ROWS; i++) {
682
+ const c = chunk.charCodeAt(i);
683
+ if (c === LF || c === CR) {
684
+ this.lineBreak(c);
685
+ } else {
686
+ this.character(c);
687
+ }
688
+ this.lastWasCr = c === CR;
689
+ }
690
+ }
691
+
692
+ /**
693
+ * A line break: counted unless it is the LF of a CR LF or ends a leading comment line.
694
+ * @param c - LF or CR
695
+ */
696
+ private lineBreak(c: number): void {
697
+ if (!(c === LF && this.lastWasCr) && !this.inComment) {
698
+ this.breaks++;
699
+ }
700
+ this.inComment = false;
701
+ this.lineStart = true;
702
+ }
703
+
704
+ /**
705
+ * Any other character: the first of a line decides whether a leading comment line starts.
706
+ * @param c - the character code
707
+ */
708
+ private character(c: number): void {
709
+ if (this.lineStart && this.leading) {
710
+ this.inComment = this.commentCodes.includes(c);
711
+ this.leading = this.inComment;
712
+ }
713
+ this.lineStart = false;
714
+ }
715
+ }
716
+
671
717
  /**
672
718
  * The record state machine of RecordReader, resumable at every record end so the reader can
673
719
  * yield a record before the next one overwrites the shared cell arrays. Every state lives on the
@@ -87,6 +87,16 @@ function isDigit(c: number): boolean {
87
87
  return c >= 48 && c <= 57;
88
88
  }
89
89
 
90
+ /**
91
+ * Whether a code unit is a blank between tokens: space, tab, vertical tab, form feed, or a U+FEFF
92
+ * after the start (concatenated files), which the shared text check reports.
93
+ * @param c - the code unit
94
+ * @returns true for a blank
95
+ */
96
+ function isBlank(c: number): boolean {
97
+ return c === 0x20 || c === 0x09 || c === 0x0b || c === 0x0c || c === BOM_CODE;
98
+ }
99
+
90
100
  /**
91
101
  * A pull lexer over one DOT document with arbitrary lookahead.
92
102
  * @category Plugin helpers
@@ -247,40 +257,68 @@ export class DotTokenizer {
247
257
  this.pos = i + 1;
248
258
  return { kind: "id", text: out, quoted: true, html: false, line };
249
259
  }
250
- if (c === 0x5c) {
251
- const d = text.charCodeAt(i + 1);
252
- if (d === 0x22) {
253
- out += `${text.slice(segment, i)}"`;
254
- i += 2;
255
- segment = i;
256
- continue;
257
- }
258
- if (d === 0x5c) {
259
- // Graphviz's scanner consumes a backslash pair as one unit (both kept), so the
260
- // second backslash never escapes a closing quote: "x\\" is the text x\\
261
- i += 2;
262
- continue;
263
- }
264
- if (d === 0x0a || d === 0x0d) {
265
- out += text.slice(segment, i);
266
- i += d === 0x0d && text.charCodeAt(i + 2) === 0x0a ? 3 : 2;
267
- this.line++;
268
- segment = i;
269
- continue;
270
- }
271
- i++;
272
- continue;
273
- }
274
- if (c === 0x0a || c === 0x0d) {
275
- if (c === 0x0d && text.charCodeAt(i + 1) === 0x0a) {
276
- i++;
260
+ if (c === 0x5c && this.isQuotedSplice(i)) {
261
+ out += text.slice(segment, i);
262
+ if (text.codePointAt(i + 1) === 0x22) {
263
+ out += '"';
277
264
  }
278
- this.line++;
265
+ i = this.quotedSpliceEnd(i);
266
+ segment = i;
267
+ } else {
268
+ i = this.quotedStep(i, c);
279
269
  }
280
- i++;
281
270
  }
282
271
  }
283
272
 
273
+ /**
274
+ * Whether the backslash at `i` inside a quoted string starts a sequence that changes the decoded
275
+ * text: `\"` (an escaped quote) or a backslash before a line break (a line continuation).
276
+ * @param i - the offset of the backslash
277
+ * @returns true for an escaped quote or a line continuation
278
+ */
279
+ private isQuotedSplice(i: number): boolean {
280
+ const d = this.text.codePointAt(i + 1);
281
+ return d === 0x22 || d === 0x0a || d === 0x0d;
282
+ }
283
+
284
+ /**
285
+ * The offset just past an escaped quote or a line continuation, counting the line a
286
+ * continuation ends.
287
+ * @param i - the offset of the backslash
288
+ * @returns the offset after the sequence
289
+ */
290
+ private quotedSpliceEnd(i: number): number {
291
+ const { text } = this;
292
+ const d = text.charCodeAt(i + 1);
293
+ if (d === 0x22) {
294
+ return i + 2;
295
+ }
296
+ this.line++;
297
+ return d === 0x0d && text.codePointAt(i + 2) === 0x0a ? i + 3 : i + 2;
298
+ }
299
+
300
+ /**
301
+ * Step over one unit of a quoted string kept as written: a backslash pair (consumed as one unit,
302
+ * as Graphviz's scanner does, so the second backslash never escapes a closing quote: "x\\" is
303
+ * the text x\\), a lone backslash, a raw line break (counted) or any other character.
304
+ * @param i - the offset of the unit
305
+ * @param c - the code unit at `i`
306
+ * @returns the offset after the unit
307
+ */
308
+ private quotedStep(i: number, c: number): number {
309
+ const { text } = this;
310
+ if (c === 0x5c) {
311
+ return text.codePointAt(i + 1) === 0x5c ? i + 2 : i + 1;
312
+ }
313
+ if (c === 0x0a) {
314
+ this.line++;
315
+ } else if (c === 0x0d) {
316
+ this.line++;
317
+ return text.codePointAt(i + 1) === 0x0a ? i + 2 : i + 1;
318
+ }
319
+ return i + 1;
320
+ }
321
+
284
322
  /**
285
323
  * Lex an HTML string starting at the opening angle bracket: the text up to the matching
286
324
  * closing bracket, brackets balanced, kept verbatim including the outer pair.
@@ -313,51 +351,63 @@ export class DotTokenizer {
313
351
  */
314
352
  private skipTrivia(): void {
315
353
  const { text } = this;
316
- for (;;) {
317
- if (this.pos >= text.length) {
318
- return;
319
- }
354
+ while (this.pos < text.length) {
320
355
  const c = text.charCodeAt(this.pos);
321
- if (c === 0x0a) {
322
- this.line++;
323
- this.pos++;
324
- this.skipPreprocessorLine();
325
- continue;
326
- }
327
- if (c === 0x0d) {
328
- this.line++;
329
- this.pos += text.charCodeAt(this.pos + 1) === 0x0a ? 2 : 1;
330
- this.skipPreprocessorLine();
331
- continue;
332
- }
333
- if (c === 0x20 || c === 0x09 || c === 0x0b || c === 0x0c) {
334
- this.pos++;
335
- continue;
336
- }
337
- if (c === BOM_CODE) {
338
- // a U+FEFF after the start (concatenated files) is whitespace; the shared text check reports it
356
+ if (isBlank(c)) {
339
357
  this.pos++;
340
- continue;
341
- }
342
- if (c === 0x2f) {
343
- const d = text.charCodeAt(this.pos + 1);
344
- if (d === 0x2f) {
345
- this.skipToLineEnd();
346
- continue;
347
- }
348
- if (d === 0x2a) {
349
- this.skipBlockComment();
350
- continue;
351
- }
352
- }
353
- if (c === 0x23 && this.pos === 0) {
354
- this.skipToLineEnd();
355
- continue;
358
+ } else if (!this.skipBreakOrComment(c)) {
359
+ return;
356
360
  }
357
- return;
358
361
  }
359
362
  }
360
363
 
364
+ /**
365
+ * Skip one line break, comment or leading `#` line at the current position.
366
+ * @param c - the code unit at the current position
367
+ * @returns false when the current position starts a token
368
+ */
369
+ private skipBreakOrComment(c: number): boolean {
370
+ if (c === 0x0a || c === 0x0d) {
371
+ this.skipLineBreak(c);
372
+ return true;
373
+ }
374
+ if (c === 0x2f) {
375
+ return this.skipComment();
376
+ }
377
+ if (c === 0x23 && this.pos === 0) {
378
+ this.skipToLineEnd();
379
+ return true;
380
+ }
381
+ return false;
382
+ }
383
+
384
+ /**
385
+ * Consume the line break at the current position (CR LF as one) and a `#` line after it.
386
+ * @param c - the code unit at the current position, LF or CR
387
+ */
388
+ private skipLineBreak(c: number): void {
389
+ this.line++;
390
+ this.pos += c === 0x0d && this.text.codePointAt(this.pos + 1) === 0x0a ? 2 : 1;
391
+ this.skipPreprocessorLine();
392
+ }
393
+
394
+ /**
395
+ * Skip a `//` or block comment starting at the current `/`.
396
+ * @returns false when the `/` starts no comment
397
+ */
398
+ private skipComment(): boolean {
399
+ const d = this.text.codePointAt(this.pos + 1);
400
+ if (d === 0x2f) {
401
+ this.skipToLineEnd();
402
+ return true;
403
+ }
404
+ if (d === 0x2a) {
405
+ this.skipBlockComment();
406
+ return true;
407
+ }
408
+ return false;
409
+ }
410
+
361
411
  /**
362
412
  * Discard a `#` line when one starts at the current position (the start of a line).
363
413
  */