omgkit-io 0.0.2

SMILES and SMARTS parsing and writing for omgkit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
//! SMILES 解析 —— 手写递归下降。
//!
//! # 为什么是手写而不是生成的解析器
//!
//! SMILES 本质是**带环闭合的 DFS 序列**,LL(1) 足够。手写换来三样东西:
//! 精确到列的错误位置、零拷贝、以及没有构建期依赖。
//!
//! # 本层只做纯解析
//!
//! 不含任何化学语义:
//!
//! - 不做芳香性感知 —— 小写原子只是打上 [`AtomFlags::AROMATIC`],记录"作者
//!   如此声称",是否真芳香由后续的净化判定
//! - 不做价键计算、不推断隐式氢 —— `num_implicit_hs` 恒为 0
//! - 不做环感知 —— [`BondFlags::IN_RING`](omgkit_core::BondFlags::IN_RING) 不置位
//!
//! # 约定一:环闭合键延后追加,按环标号排序
//!
//! 环闭合键**全部推到键表末尾**,不在书写位置就地插入。`C1CC2CCC1CC2` 的
//! 键序因此是 `(0,1)(1,2)(2,3)(3,4)(4,5)(5,6)(6,7)(5,0)(7,2)` —— 两条环键
//! 在最后,即使 `(5,0)` 在书写上早于 `(5,6)`。
//!
//! 环键之间按**环标号升序**排列,而不是闭合先后:
//!
//! ```text
//! C2CC2C1CC1        环键 = [(5,3), (2,0)]   标号 1 在前,尽管标号 2 先闭合
//! C%10CC1CCC%10CC1  环键 = [(7,2), (5,0)]   1 < 10
//! C1CC1C1CC1        环键 = [(2,0), (5,3)]   同标号复用则按出现顺序
//! ```
//!
//! 端点方向与直觉相反:**`begin` 是闭合原子,`end` 是开环原子**
//! (`C1CCCCC1` → `(5,0)`)。唯一的例外是开环端写了键级符号,那时两端对调 ——
//! 见 `Parser::ring_closure`。
//!
//! # 约定二:手性标记相对**存储序**
//!
//! 由于约定一,原子的邻居存储顺序偏离书写顺序,手性标记必须补偿:
//!
//! ```text
//! nSwaps = 置换宇称(存储的键顺序 ↔ 书写的键顺序)   // 只算键,不含隐式氢
//! if needs_tag_inversion(...) { nSwaps += 1 }
//! if nSwaps 为奇 { 翻转标记 }
//! ```
//!
//! 其中 `needs_tag_inversion` 为:
//!
//! ```text
//! degree == 3 && ( (是片段首原子 && 显式氢数 == 1)
//!               || (显式氢数 != 1 && 环闭合数 == 1 && !不饱和) )
//! ```
//!
//! **隐式氢不参与置换**,这一点强烈反直觉。把 H 当成一个槽位塞进置换的两种
//! 自然做法都是错的:
//!
//! | 用例 | 正确结果 | "H 排最后" | "H 在 index 1" |
//! |---|---|---|---|
//! | `[C@H](N)(O)F` | 翻转 | ✓ | ✓ |
//! | `N[C@H](O)F` | 不翻 | ✓ | ✓ |
//! | `[C@@H]1CCCCC1O` | 翻转 | ✓ | ✗ |
//! | `C[P@H]C` | 不翻 | ✗ | ✓ |
//!
//! 两种建模各能解释一半用例。正确的模型是把 H 完全排除在置换之外,改由
//! 上面那个 `degree == 3` 的特判补偿。
//!
//! 书写序按**邻居原子序号**排序重建:非环键照序号排,环闭合键整体插在"自身"
//! 所处的位置。DFS 下前驱原子序号必小于本原子、后续原子序号必大于本原子,
//! 所以序号序等价于书写序。
//!
//! 之所以用"相对存储序"而不是"相对书写序":分子会被反应编辑,那时书写顺序
//! 早已不存在,只有相对存储序的约定能活下来。

mod write;

pub use write::{write, write_with_priority, write_with_priority_styled, WriteStyle, Written};

use std::collections::{HashMap, HashSet};

use omgkit_core::{AtomData, AtomFlags, BondData, BondDirection, BondOrder, ChiralTag, MolBuilder};

use crate::error::{ParseError, ParseErrorKind as K, Result};

/// 解析一条 SMILES。
///
/// 返回**未净化**的分子:芳香性未感知、隐式氢未推断、环未感知。
///
/// # Errors
/// 语法错误时返回带位置的 [`ParseError`];用 [`ParseError::render`] 可得到
/// 带插字号的两行视图。
pub fn parse(input: &str) -> Result<MolBuilder> {
    Parser::new(input.as_bytes()).run()
}

/// 解析 `.smi` 的一行:`SMILES[<空白>名字]`。
///
/// # Errors
/// 同 [`parse`]。
pub fn parse_line(line: &str) -> Result<MolBuilder> {
    let line = line.trim();
    let (smi, name) = match line.find(char::is_whitespace) {
        Some(i) => (&line[..i], line[i..].trim()),
        None => (line, ""),
    };
    let mut mol = parse(smi)?;
    if !name.is_empty() {
        mol.set_name(name);
    }
    Ok(mol)
}

// ---------------------------------------------------------------------------

/// 一个待配对的环闭合。
#[derive(Debug, Clone, Copy)]
struct RingOpen {
    atom: u32,
    order: Option<BondOrder>,
    /// 开环端是否写了**键级**符号(`- = # $ :`)。
    /// `/` `\` 只是方向符号,不算 —— 这个区分决定环键端点的朝向,见模块文档。
    explicit_order: bool,
    /// 开环端写的是 `<-`
    swap_ends: bool,
    dir: BondDirection,
    /// 开环流水号,用于把最终键下标关联回手性记录的 `ring_seqs`
    seq: u32,
    /// 开环处的位置,报错用
    pos: usize,
}

/// 已配对、待追加到键表末尾的环键。
///
/// 追加顺序按 `(number, seq)` 排序 —— 见模块文档约定 1。
#[derive(Debug, Clone, Copy)]
struct RingBond {
    /// 环标号(`1`、`%10`、`%(123)` 的数值)
    number: u32,
    /// 闭合发生的先后,同标号复用时用于稳定排序
    seq: u32,
    /// 开环流水号,用于把最终键下标回填给手性记录
    open_seq: u32,
    /// 闭合原子(放在 begin,见模块文档约定一)
    begin: u32,
    /// 开环原子
    end: u32,
    order: BondOrder,
    dir: BondDirection,
}

/// 尚未消费的键符号。
#[derive(Debug, Clone, Copy)]
struct Pending {
    /// 该符号指定的键级。`/` `\` 是纯方向标记,不指定键级,故为 `None`
    /// (键级仍走默认规则,两端芳香则为芳香键)。
    order: Option<BondOrder>,
    /// 是否是**键级**符号。`/` `\` 为 `false` —— 这决定环键端点朝向。
    explicit_order: bool,
    /// 配位键写成 `<-` 时置位:电子对由**右**侧原子提供,故端点要对调。
    /// `->` 与其余符号为 `false`。
    swap_ends: bool,
    dir: BondDirection,
    pos: usize,
}

/// 需要在解析结束后修正宇称的手性原子。
#[derive(Debug, Clone)]
struct ChiralRec {
    atom: u32,
    tag: ChiralTag,
    /// 是否是某个 `.` 片段的首原子;手性宇称补偿要用
    is_smiles_start: bool,
    /// 方括号中书写的氢数
    num_explicit_hs: u8,
}

struct Parser<'a> {
    src: &'a [u8],
    pos: usize,
    mol: MolBuilder,
    prev: Option<u32>,
    /// 分支栈:(返回到的原子, 入栈时的原子数 —— 用于检测空分支)
    branches: Vec<(u32, usize)>,
    rings: HashMap<u32, RingOpen>,
    ring_bonds: Vec<RingBond>,
    ring_seq: u32,
    pending: Option<Pending>,
    chiral: Vec<ChiralRec>,
    chiral_of: HashMap<u32, usize>,
    /// 原子 → 该原子上的环闭合开环流水号,按在此原子处书写的先后。
    ///
    /// 开环端与闭合端都要记。**每个原子都记**,不只是带手性标记的那些:
    /// 丙二烯的宇称要看两个端原子各自的书写序,而端原子自己不带标记。
    ring_seqs_of: HashMap<u32, Vec<u32>>,
    /// 开环流水号 → 最终键下标,在环键追加后填好
    ring_bond_index: HashMap<u32, u32>,
    /// 待建环键的端点对(已归一为 `(min, max)`),供 [`Parser::bond_exists`] O(1) 查重。
    ///
    /// 环键要到解析末尾才统一追加进分子,所以在那之前它们不在 `mol.bonds()` 里。
    /// 线性扫 `ring_bonds` 的话,每次闭环都是 O(已有环键数),环多的分子直接
    /// 退化成平方。
    pending_ring_pairs: HashSet<(u32, u32)>,
}

/// 把一对端点归一成 `(min, max)`,使查重与书写方向无关。
fn endpoint_pair(a: u32, b: u32) -> (u32, u32) {
    if a <= b {
        (a, b)
    } else {
        (b, a)
    }
}

impl<'a> Parser<'a> {
    fn new(src: &'a [u8]) -> Self {
        Self {
            src,
            pos: 0,
            mol: MolBuilder::new(),
            prev: None,
            branches: Vec::new(),
            rings: HashMap::new(),
            ring_bonds: Vec::new(),
            ring_seq: 0,
            pending: None,
            chiral: Vec::new(),
            ring_seqs_of: HashMap::new(),
            chiral_of: HashMap::new(),
            ring_bond_index: HashMap::new(),
            pending_ring_pairs: HashSet::new(),
        }
    }

    fn err<T>(&self, kind: K, pos: usize) -> Result<T> {
        Err(ParseError::new(kind, pos, self.src))
    }

    fn peek(&self) -> Option<u8> {
        self.src.get(self.pos).copied()
    }

    fn bump(&mut self) -> Option<u8> {
        let b = self.peek();
        if b.is_some() {
            self.pos += 1;
        }
        b
    }

    fn eat(&mut self, b: u8) -> bool {
        if self.peek() == Some(b) {
            self.pos += 1;
            true
        } else {
            false
        }
    }

    // -- 主循环 ------------------------------------------------------------

    fn run(mut self) -> Result<MolBuilder> {
        if self.src.is_empty() {
            return self.err(K::Empty, 0);
        }

        while let Some(b) = self.peek() {
            match b {
                b'(' => self.open_branch()?,
                b')' => self.close_branch()?,
                b'[' => self.bracket_atom()?,
                b'.' => {
                    if let Some(p) = self.pending {
                        return self.err(K::DanglingBond, p.pos);
                    }
                    self.pos += 1;
                    self.prev = None;
                }
                b'-' | b'=' | b'#' | b'$' | b':' | b'/' | b'\\' | b'<' => self.bond_symbol()?,
                b'0'..=b'9' => {
                    let pos = self.pos;
                    let n = u32::from(self.bump().expect("已 peek") - b'0');
                    self.ring_closure(n, pos)?;
                }
                b'%' => {
                    let pos = self.pos;
                    self.pos += 1;
                    let n = self.ring_number()?;
                    self.ring_closure(n, pos)?;
                }
                _ => self.organic_atom()?,
            }
        }

        // -- 收尾校验 --
        if let Some(p) = self.pending {
            return self.err(K::DanglingBond, p.pos);
        }
        if let Some(&(_, _)) = self.branches.last() {
            let pos = self.pos;
            return self.err(K::UnbalancedParen, pos);
        }
        // 注意:取 `HashMap` 的任意一项会让报错随运行而变。多个环都没闭合时,
        // 报**最先出现**的那个 —— 既确定,也最贴近用户视角。
        if let Some((&num, open)) = self.rings.iter().min_by_key(|(_, o)| o.pos) {
            let pos = open.pos;
            return self.err(K::UnclosedRingBond(num), pos);
        }
        if self.mol.num_atoms() == 0 {
            return self.err(K::Empty, 0);
        }

        // 环键在此统一追加,见模块文档约定一。
        // 排序键是 (环标号, 闭合先后)。
        let mut ring_bonds = std::mem::take(&mut self.ring_bonds);
        ring_bonds.sort_by_key(|r| (r.number, r.seq));
        let (pos, src) = (self.pos, self.src);
        for rb in ring_bonds {
            let mut bd = BondData::new(rb.begin, rb.end, rb.order);
            bd.direction = rb.dir;
            mark_aromatic(&mut bd);
            let idx = self
                .mol
                .add_bond_data(bd)
                .map_err(|_| ParseError::new(K::RingBondToSelf(rb.number), pos, src))?;
            self.ring_bond_index.insert(rb.open_seq, idx);
        }

        self.fix_chirality();
        Ok(self.mol)
    }

    // -- 分支 --------------------------------------------------------------

    fn open_branch(&mut self) -> Result<()> {
        let pos = self.pos;
        self.pos += 1;
        match self.prev {
            Some(a) => {
                self.branches.push((a, self.mol.num_atoms()));
                Ok(())
            }
            None => self.err(K::UnbalancedParen, pos),
        }
    }

    fn close_branch(&mut self) -> Result<()> {
        let pos = self.pos;
        self.pos += 1;
        match self.branches.pop() {
            Some((atom, n_at_open)) => {
                if self.mol.num_atoms() == n_at_open {
                    return self.err(K::EmptyBranch, pos);
                }
                if let Some(p) = self.pending {
                    return self.err(K::DanglingBond, p.pos);
                }
                self.prev = Some(atom);
                Ok(())
            }
            None => self.err(K::UnbalancedParen, pos),
        }
    }

    // -- 键符号 ------------------------------------------------------------

    fn bond_symbol(&mut self) -> Result<()> {
        let pos = self.pos;
        if self.pending.is_some() {
            return self.err(K::DanglingBond, pos);
        }
        let b = self.bump().expect("已 peek");
        // `/` `\` 是**纯方向**标记,不指定键级 —— 键级仍走默认规则。
        //
        // OpenSMILES 把方向键描述为"单键",照字面实现会错:
        // `Cc1cs/c(=N\C(C)C)/n1...` 中 `s/c` 这条键应当是 AROMATIC,
        // 因为两端都是芳香原子。误当单键会造成几十条差分分歧。
        //
        // 这个区分还决定环闭合键的端点朝向(见模块文档约定 1):
        // `C=1CCCCC1` 交换端点,`C/1CCCCC1` 不交换。
        let (order, explicit_order, swap_ends, dir) = match b {
            // `->` 是配位键,`-` 单独出现才是单键
            b'-' => {
                if self.eat(b'>') {
                    (Some(BondOrder::Dative), true, false, BondDirection::None)
                } else {
                    (Some(BondOrder::Single), true, false, BondDirection::None)
                }
            }
            // `<` 只可能是 `<-` 的开头
            b'<' => {
                if !self.eat(b'-') {
                    return self.err(K::UnexpectedChar('<'), pos);
                }
                (Some(BondOrder::Dative), true, true, BondDirection::None)
            }
            b'=' => (Some(BondOrder::Double), true, false, BondDirection::None),
            b'#' => (Some(BondOrder::Triple), true, false, BondDirection::None),
            b'$' => (Some(BondOrder::Quadruple), true, false, BondDirection::None),
            b':' => (Some(BondOrder::Aromatic), true, false, BondDirection::None),
            b'/' => (None, false, false, BondDirection::UpRight),
            b'\\' => {
                // `\\`(两个字面反斜杠)当作单个方向键 —— 有些 SMILES 写出
                // 工具会转义反斜杠,故 `F/C=C\\F` 与 `F/C=C\F` 等价。
                // 真实语料里这种写法并不罕见,不兼容会直接
                // 造成上百条差分分歧。
                self.eat(b'\\');
                (None, false, false, BondDirection::DownRight)
            }
            _ => unreachable!("由调用方 match 保证"),
        };
        self.pending = Some(Pending {
            order,
            explicit_order,
            swap_ends,
            dir,
            pos,
        });
        Ok(())
    }

    // -- 原子 --------------------------------------------------------------

    /// 有机子集原子(可不写方括号):B C N O P S F Cl Br I + 芳香 b c n o p s
    fn organic_atom(&mut self) -> Result<()> {
        let pos = self.pos;
        let b = self.peek().expect("已 peek");

        // 双字符元素必须先于单字符匹配,否则 Cl 会被读成 C 再撞上 l
        let two = self.src.get(self.pos + 1).copied();
        let (z, len, aromatic) = match (b, two) {
            (b'C', Some(b'l')) => (17u8, 2usize, false),
            (b'B', Some(b'r')) => (35, 2, false),
            (b'B', _) => (5, 1, false),
            (b'C', _) => (6, 1, false),
            (b'N', _) => (7, 1, false),
            (b'O', _) => (8, 1, false),
            (b'P', _) => (15, 1, false),
            (b'S', _) => (16, 1, false),
            (b'F', _) => (9, 1, false),
            (b'I', _) => (53, 1, false),
            (b'b', _) => (5, 1, true),
            (b'c', _) => (6, 1, true),
            (b'n', _) => (7, 1, true),
            (b'o', _) => (8, 1, true),
            (b'p', _) => (15, 1, true),
            (b's', _) => (16, 1, true),
            (b'*', _) => (0, 1, false),
            _ => {
                let c = char::from(b);
                return self.err(K::UnexpectedChar(c), pos);
            }
        };
        self.pos += len;

        let mut atom = AtomData::new(z);
        if aromatic {
            atom.flags.insert(AtomFlags::AROMATIC);
        }
        // 有机子集原子不写方括号 → 隐式氢由 L2 推断,此处不置 NO_IMPLICIT
        self.push_atom(atom, None, pos)
    }

    /// `[` isotope? symbol chiral? hcount? charge? (`:` class)? `]`
    fn bracket_atom(&mut self) -> Result<()> {
        let open_pos = self.pos;
        self.pos += 1; // '['

        let isotope = self.opt_number(5)?;

        // -- 元素符号 --
        let sym_pos = self.pos;
        let (z, aromatic) = self.bracket_symbol(sym_pos)?;

        // -- 立体 --
        let (tag, stereo_perm) = self.opt_chirality()?;

        // -- 氢数 --
        let mut hcount = 0u8;
        if self.peek() == Some(b'H') {
            self.pos += 1;
            hcount = match self.opt_number(2)? {
                Some(n) => u8::try_from(n)
                    .map_err(|_| ParseError::new(K::NumberOverflow, self.pos, self.src))?,
                None => 1,
            };
        }

        // -- 电荷 --
        let charge = self.opt_charge()?;

        // -- 原子映射号 --
        let mut map = 0u16;
        if self.eat(b':') {
            let n = self.opt_number(5)?.ok_or_else(|| {
                ParseError::new(K::BadBracketAtom("`:` 后缺少映射号"), self.pos, self.src)
            })?;
            map = u16::try_from(n)
                .map_err(|_| ParseError::new(K::NumberOverflow, self.pos, self.src))?;
        }

        if !self.eat(b']') {
            let kind = if self.peek().is_none() {
                K::UnexpectedEnd
            } else {
                K::BadBracketAtom("方括号未正确闭合")
            };
            return self.err(kind, self.pos);
        }

        let mut atom = AtomData::new(z);
        atom.isotope = isotope
            .map(|n| u16::try_from(n).unwrap_or(u16::MAX))
            .unwrap_or(0);
        atom.num_explicit_hs = hcount;
        atom.formal_charge = charge;
        atom.atom_map = map;
        atom.chiral_tag = tag;
        atom.stereo_perm = stereo_perm;
        // 方括号原子的氢数由作者显式给定,后续不再推断。
        // 这对**所有**方括号原子成立,包括 `[CH4]` `[Fe+2]` `[*:1]`
        atom.flags.insert(AtomFlags::NO_IMPLICIT);
        if aromatic {
            atom.flags.insert(AtomFlags::AROMATIC);
        }

        self.push_atom(atom, Some((tag, hcount)), open_pos)
    }

    fn bracket_symbol(&mut self, pos: usize) -> Result<(u8, bool)> {
        let b = match self.peek() {
            Some(b) => b,
            None => return self.err(K::UnexpectedEnd, pos),
        };

        if b == b'*' {
            self.pos += 1;
            return Ok((0, false));
        }

        if b.is_ascii_uppercase() {
            // 贪心取两字符,失败再退回一字符 —— `[Cl]` 与 `[C@]` 都要正确
            let mut sym = String::new();
            sym.push(char::from(b));
            let second = self.src.get(self.pos + 1).copied();
            if let Some(c) = second {
                if c.is_ascii_lowercase() {
                    let two: String = [char::from(b), char::from(c)].iter().collect();
                    if let Some(z) = omgkit_core::element::atomic_num_of(&two) {
                        self.pos += 2;
                        return Ok((z, false));
                    }
                }
            }
            if let Some(z) = omgkit_core::element::atomic_num_of(&sym) {
                self.pos += 1;
                return Ok((z, false));
            }
            if let Some(c) = second {
                if c.is_ascii_lowercase() {
                    sym.push(char::from(c));
                }
            }
            return self.err(K::UnknownElement(sym), pos);
        }

        if b.is_ascii_lowercase() {
            // 芳香形式:单字符 b c n o p s,双字符 se as te
            let second = self.src.get(self.pos + 1).copied();
            if let Some(c) = second {
                if c.is_ascii_lowercase() {
                    let two: String = [char::from(b), char::from(c)].iter().collect();
                    let up = format!("{}{}", two[..1].to_ascii_uppercase(), &two[1..]);
                    if matches!(two.as_str(), "se" | "as" | "te" | "si") {
                        if let Some(z) = omgkit_core::element::atomic_num_of(&up) {
                            self.pos += 2;
                            return Ok((z, true));
                        }
                    }
                }
            }
            let up = char::from(b).to_ascii_uppercase().to_string();
            if let Some(z) = omgkit_core::element::atomic_num_of(&up) {
                if omgkit_core::element::can_be_aromatic_lowercase(z) {
                    self.pos += 1;
                    return Ok((z, true));
                }
            }
            return self.err(K::UnknownElement(char::from(b).to_string()), pos);
        }

        self.err(K::BadBracketAtom("缺少元素符号"), pos)
    }

    /// 读立体标记,返回(几何类别, 类内排列序号)。
    ///
    /// 支持简写 `@` / `@@` 与扩展形式 `@TH1` `@AL1` `@SP1` `@TB15` `@OH25`。
    ///
    /// # 序号的取值范围要当场校验
    ///
    /// 每种几何的排列数是**几何本身决定的**:配体在多面体位置上的排法除以
    /// 该多面体的转动群阶数 —— 平面四方 4!/8 = 3,三角双锥 5!/6 = 20,
    /// 八面体 6!/24 = 30。所以 `@TB21` 不是"某个暂时不认识的排列",而是一个
    /// **不存在**的排列,只能是笔误。放它过去等于把一个无法解释的数字
    /// 塞进分子里,越往后越难查。
    ///
    /// 序号 0 与省略序号(光秃的 `@SP`)都表示"有这个几何但没指定排列",
    /// 是合法的。
    ///
    /// # 四面体的序号不单独存
    ///
    /// `TH1` 就是 `@`、`TH2` 就是 `@@`,排列已由类别本身表达,再存一份序号
    /// 就有了两个可以互相矛盾的真相来源。故四面体一律返回 0。
    fn opt_chirality(&mut self) -> Result<(ChiralTag, u8)> {
        if self.peek() != Some(b'@') {
            return Ok((ChiralTag::Unspecified, 0));
        }
        self.pos += 1;
        if self.eat(b'@') {
            return Ok((ChiralTag::Cw, 0));
        }
        let rest = &self.src[self.pos..];
        let two = rest.get(..2).map(<[u8]>::to_ascii_uppercase);
        // (几何书写形式, 最大序号, 类别)
        let (name, max, class) = match two.as_deref() {
            Some(b"TH") => ("TH", 2, ChiralTag::Ccw),
            // 丙二烯轴手性:立体信息属于一根轴而非一个配位中心,不归入配位几何
            Some(b"AL") => ("AL", 2, ChiralTag::Allene),
            Some(b"SP") => ("SP", 3, ChiralTag::SquarePlanar),
            Some(b"TB") => ("TB", 20, ChiralTag::TrigonalBipyramidal),
            Some(b"OH") => ("OH", 30, ChiralTag::Octahedral),
            // 光秃秃的 `@` 就是四面体逆时针
            _ => return Ok((ChiralTag::Ccw, 0)),
        };
        self.pos += 2;
        let pos = self.pos;
        let perm = self.opt_number(2)?.unwrap_or(0);
        if perm > max {
            return self.err(
                K::StereoPermOutOfRange {
                    geometry: name,
                    got: perm,
                    max,
                },
                pos,
            );
        }
        if name == "TH" {
            // TH1 = `@` = 逆时针,TH2 = `@@` = 顺时针
            return Ok((
                if perm == 2 {
                    ChiralTag::Cw
                } else {
                    ChiralTag::Ccw
                },
                0,
            ));
        }
        Ok((class, u8::try_from(perm).unwrap_or(0)))
    }

    fn opt_charge(&mut self) -> Result<i8> {
        let sign = match self.peek() {
            Some(b'+') => 1i8,
            Some(b'-') => -1i8,
            _ => return Ok(0),
        };
        self.pos += 1;

        // `++` / `--` 形式
        let mut n = 1i32;
        while self.peek() == Some(if sign > 0 { b'+' } else { b'-' }) {
            self.pos += 1;
            n += 1;
        }
        if n > 1 {
            return i8::try_from(n * i32::from(sign))
                .map_err(|_| ParseError::new(K::NumberOverflow, self.pos, self.src));
        }

        // `+2` 形式
        if let Some(v) = self.opt_number(2)? {
            n = i32::try_from(v).unwrap_or(i32::MAX);
        }
        i8::try_from(n * i32::from(sign))
            .map_err(|_| ParseError::new(K::NumberOverflow, self.pos, self.src))
    }

    /// 读至多 `max_digits` 位十进制数;无数字时返回 `None`。
    fn opt_number(&mut self, max_digits: usize) -> Result<Option<u32>> {
        let start = self.pos;
        let mut val: u64 = 0;
        let mut n = 0;
        while n < max_digits {
            match self.peek() {
                Some(d @ b'0'..=b'9') => {
                    val = val * 10 + u64::from(d - b'0');
                    self.pos += 1;
                    n += 1;
                }
                _ => break,
            }
        }
        if n == 0 {
            return Ok(None);
        }
        u32::try_from(val)
            .map(Some)
            .map_err(|_| ParseError::new(K::NumberOverflow, start, self.src))
    }

    /// `%NN` 或 `%(N...)`
    fn ring_number(&mut self) -> Result<u32> {
        if self.eat(b'(') {
            let n = self.opt_number(5)?.ok_or_else(|| {
                ParseError::new(K::BadBracketAtom("`%(` 后缺少数字"), self.pos, self.src)
            })?;
            if !self.eat(b')') {
                return self.err(K::BadBracketAtom("`%(` 未闭合"), self.pos);
            }
            return Ok(n);
        }
        let pos = self.pos;
        match self.opt_number(2)? {
            Some(n) if self.pos - pos == 2 => Ok(n),
            _ => self.err(K::BadBracketAtom("`%` 后需要两位数字"), pos),
        }
    }

    // -- 原子入图 ----------------------------------------------------------

    /// 把原子加入分子,并处理与前一个原子之间的键。
    ///
    /// `chiral` 为 `Some((tag, has_h))` 时登记手性记录。
    fn push_atom(
        &mut self,
        atom: AtomData,
        chiral: Option<(ChiralTag, u8)>,
        pos: usize,
    ) -> Result<()> {
        let aromatic = atom.flags.contains(AtomFlags::AROMATIC);
        let idx = self.mol.add_atom_data(atom);

        // 手性记录须在成键前建立 —— `is_smiles_start` 要看的是"此刻还没有前驱原子"
        if let Some((tag, num_explicit_hs)) = chiral {
            if tag != ChiralTag::Unspecified {
                self.chiral_of.insert(idx, self.chiral.len());
                self.chiral.push(ChiralRec {
                    atom: idx,
                    tag,
                    is_smiles_start: self.prev.is_none(),
                    num_explicit_hs,
                });
            }
        }

        if let Some(prev) = self.prev {
            let pending = self.pending.take();
            let order = match pending.and_then(|p| p.order) {
                Some(o) => o,
                None => self.default_order(prev, aromatic),
            };
            // 配位键 `<-` 的电子对由右侧原子提供,故 begin 是**后**写的那个
            let (begin, end) = if pending.is_some_and(|p| p.swap_ends) {
                (idx, prev)
            } else {
                (prev, idx)
            };
            let mut bd = BondData::new(begin, end, order);
            bd.direction = pending.map_or(BondDirection::None, |p| p.dir);
            mark_aromatic(&mut bd);
            let src = self.src;
            self.mol
                .add_bond_data(bd)
                .map_err(|_| ParseError::new(K::UnexpectedChar('?'), pos, src))?;
        } else if let Some(p) = self.pending.take() {
            // 键符号后面没有可连接的前驱原子
            return self.err(K::DanglingBond, p.pos);
        }

        self.prev = Some(idx);
        Ok(())
    }

    /// 未显式书写键符号时的默认键级:两端都芳香则芳香键,否则单键。
    ///
    /// `c1ccccc1` 的键全为 AROMATIC,而
    /// `[O-][N+](=O)c1ccccc1` 中 N→c 的键为 SINGLE。
    fn default_order(&self, prev: u32, cur_aromatic: bool) -> BondOrder {
        let prev_aromatic = self.mol.atoms()[prev as usize]
            .flags
            .contains(AtomFlags::AROMATIC);
        if prev_aromatic && cur_aromatic {
            BondOrder::Aromatic
        } else {
            BondOrder::Single
        }
    }

    /// 记下 `atom` 这里出现的一次环闭合(按书写先后)。
    ///
    /// 开环端与闭合端都要记 —— 宇称补偿要用到两端各自的环闭合数。
    fn note_ring(&mut self, atom: u32, open_seq: u32) {
        self.ring_seqs_of.entry(atom).or_default().push(open_seq);
    }

    // -- 环闭合 ------------------------------------------------------------

    fn ring_closure(&mut self, num: u32, pos: usize) -> Result<()> {
        let cur = match self.prev {
            Some(a) => a,
            None => return self.err(K::UnexpectedChar(char::from(self.src[pos])), pos),
        };
        let pending = self.pending.take();

        match self.rings.remove(&num) {
            // 闭环
            Some(open) => {
                if open.atom == cur {
                    return self.err(K::RingBondToSelf(num), pos);
                }
                if self.bond_exists(open.atom, cur) {
                    return self.err(K::DuplicateRingBond(num), pos);
                }

                let close_order = pending.and_then(|p| p.order);
                let close_explicit = pending.is_some_and(|p| p.explicit_order);

                // 只有两端都写了**键级**符号才可能冲突;`/` 蕴含的单键不参与
                if open.explicit_order && close_explicit && open.order != close_order {
                    return self.err(K::ConflictingRingBondOrder(num), pos);
                }
                let order = match open.order.or(close_order) {
                    Some(o) => o,
                    None => {
                        let cur_arom = self.mol.atoms()[cur as usize]
                            .flags
                            .contains(AtomFlags::AROMATIC);
                        self.default_order(open.atom, cur_arom)
                    }
                };

                // 端点朝向:开环端写了键级符号时,键在开环处即已确定,故开环
                // 原子在 begin;否则键在闭合处建立,闭合原子在 begin:
                //   C1CCCCC1 → (5,0)   C=1CCCCC1 → (0,5)   C/1CCCCC1 → (5,0)
                let open_is_begin = open.explicit_order;
                let (begin, end) = if open_is_begin {
                    (open.atom, cur)
                } else {
                    (cur, open.atom)
                };

                // `<-` 在上面的朝向之上再对调一次。取的是**定了键级的那一端**
                // 写的符号 —— 与 `order` 的取法保持一致(开环端优先),否则
                // 两端都写 `->` 时键级和方向会来自不同的端。
                //   N->1CCCCC->1   → (0,5)   开环端说 N 给电子
                //   [Cu]<-1CCCC1   → (4,0)   开环端说 Cu 收电子
                //   [Cu]1<-NCCN->1 → (4,0)   开环端没定键级,听闭合端的
                let swap_ends = if open.order.is_some() {
                    open.swap_ends
                } else {
                    pending.is_some_and(|p| p.swap_ends)
                };
                // 方向符号处于书写它那一端的参考系;该端若被存为 `end` 就要翻转。
                let close_dir = pending.map_or(BondDirection::None, |p| p.dir);
                let dir = if open_is_begin {
                    if open.dir != BondDirection::None {
                        open.dir
                    } else {
                        close_dir.flipped()
                    }
                } else if close_dir != BondDirection::None {
                    close_dir
                } else {
                    open.dir.flipped()
                };

                // 端点对调后,方向也处在了相反的参考系里,要跟着翻。
                // 一端写箭头、另一端写 `/` 或 `\` 时才看得出来 ——
                // `N/1CCCCC<-1` 的方向应是 `/`,漏了这一翻就成了 `\`。
                let (begin, end, dir) = if swap_ends {
                    (end, begin, dir.flipped())
                } else {
                    (begin, end, dir)
                };

                let seq = u32::try_from(self.ring_bonds.len()).unwrap_or(u32::MAX);
                self.pending_ring_pairs.insert(endpoint_pair(begin, end));
                self.ring_bonds.push(RingBond {
                    number: num,
                    seq,
                    open_seq: open.seq,
                    begin,
                    end,
                    order,
                    dir,
                });

                // 闭合端也要记这次环闭合(开环端在开环时已记)
                self.note_ring(cur, open.seq);
            }
            // 开环
            None => {
                let seq = self.ring_seq;
                self.ring_seq += 1;
                self.rings.insert(
                    num,
                    RingOpen {
                        atom: cur,
                        order: pending.and_then(|p| p.order),
                        explicit_order: pending.is_some_and(|p| p.explicit_order),
                        swap_ends: pending.is_some_and(|p| p.swap_ends),
                        dir: pending.map_or(BondDirection::None, |p| p.dir),
                        seq,
                        pos,
                    },
                );
                self.note_ring(cur, seq);
            }
        }
        Ok(())
    }

    /// `a` 与 `b` 之间是否已有键 —— 既查已建的,也查待建的环键。
    ///
    /// 两边都是 O(1)/O(度数):分子侧走 `MolBuilder` 的邻接索引,
    /// 待建侧走 [`Parser::pending_ring_pairs`]。
    fn bond_exists(&self, a: u32, b: u32) -> bool {
        self.mol.bond_between(a, b).is_some()
            || self.pending_ring_pairs.contains(&endpoint_pair(a, b))
    }

    // -- 手性宇称修正 ------------------------------------------------------

    /// 该原子上的环闭合键下标,按在此原子处书写的先后。
    fn ring_bonds_of(&self, atom: u32) -> Vec<u32> {
        self.ring_seqs_of
            .get(&atom)
            .map(|seqs| {
                seqs.iter()
                    .filter_map(|s| self.ring_bond_index.get(s).copied())
                    .collect()
            })
            .unwrap_or_default()
    }

    /// 该原子的键在**书写序**下的顺序,以及"自身位置"。
    ///
    /// 非环键按**邻居原子序号**排序,环闭合键整体插在"自身"所处的位置 ——
    /// 这样能重建 SMILES 的书写顺序,因为 DFS 下前驱原子序号必小于本原子、
    /// 后续原子序号必大于本原子。
    ///
    /// "自身位置"是环闭合开始写的地方,也是方括号里那个氢所在的地方
    /// (方括号写在环闭合标号之前)。
    fn written_bond_order(&self, atom: u32) -> (Vec<u32>, usize) {
        let ring_bonds = self.ring_bonds_of(atom);
        let mut entries: Vec<(u32, Option<u32>)> = vec![(atom, None)];
        for (i, b) in self.mol.bonds().iter().enumerate() {
            let i = i as u32;
            if ring_bonds.contains(&i) {
                continue;
            }
            if let Some(other) = b.other_end(atom) {
                entries.push((other, Some(i)));
            }
        }
        entries.sort_by_key(|e| e.0);

        let mut written = Vec::with_capacity(entries.len());
        let mut self_pos = 0;
        for (_, bond) in entries {
            match bond {
                None => {
                    self_pos = written.len();
                    written.extend(ring_bonds.iter().copied());
                }
                Some(i) => written.push(i),
            }
        }
        (written, self_pos)
    }

    /// 把每个手性标记从"书写序"转换到"存储序"。
    ///
    /// 算法见模块文档约定二:
    ///
    /// ```text
    /// nSwaps = 置换宇称(存储的键顺序 ↔ 书写的键顺序)   // 只算键,不含隐式氢
    /// if 需要补偿(见模块文档) { nSwaps += 1 }
    /// if nSwaps 为奇 { 翻转标记 }
    /// ```
    ///
    /// 隐式氢**不参与置换**,而是由那条 degree==3 的补偿规则统一处理 ——
    /// 见模块文档里的对照表。
    ///
    /// 只有四面体能这样修:它的两种排列互为镜像,一次对换即翻转。
    ///
    /// 配位几何(`@SP`/`@TB`/`@OH`)另走一条路:序号在邻居重排下按该多面体的
    /// 转动群变换,由 [`omgkit_core::polyhedron::renumber`] 归一到存储序,
    /// 见下方注释。
    fn fix_chirality(&mut self) {
        for rec in &self.chiral {
            let atom = rec.atom;
            let ring_bonds = self.ring_bonds_of(atom);
            let stored = stored_bond_order(&self.mol, atom);
            let (written, self_pos) = self.written_bond_order(atom);
            if written.len() != stored.len() {
                continue; // 结构异常,已在别处报错
            }

            // 配位几何(`@SP`/`@TB`/`@OH`):按该多面体的转动群把序号从书写序
            // 换算到存储序。
            //
            // 方括号里的氢、或者一个空的配位位置,也占多面体的一个顶点而不在
            // `written` / `stored` 这两个键序列里。书写序里它落在"自身位置"
            // (紧跟前驱原子之后、环闭合之前),存储序里按约定排最前 ——
            // 见 [`coordination_ligands`]。
            //
            // **换算不了就原样保管、也不写出去**(缺两个及以上顶点、序号越界)。
            // 写出侧用的是同一个条件(见 `write.rs::output_chiral_tag`),
            // 两侧因此对齐。
            if omgkit_core::polyhedron::ligand_count(rec.tag).is_some() {
                if let Some(to) = coordination_ligands(&self.mol, atom, rec.tag) {
                    let mut from = written.clone();
                    if to.len() == from.len() + 1 {
                        from.insert(self_pos, VACANT_LIGAND);
                    }
                    let literal = self.mol.atoms()[atom as usize].stereo_perm;
                    if let Some(p) = omgkit_core::polyhedron::renumber(rec.tag, literal, &from, &to)
                    {
                        if let Some(a) = self.mol.atom_mut(atom) {
                            a.stereo_perm = p;
                        }
                    }
                }
                continue;
            }

            // 丙二烯型轴手性:四个配体来自累积双键**两端**,中心自己只有两个
            // 邻居。`@AL1`/`@AL2` 与把 `@`/`@@` 写在中心上说的是同一件事
            // (实测外部实现:`@AL1` ≡ `@`),统一归到 `Allene` + 序号 1/2,
            // 相对四个配体的存储序。
            //
            // **写在中心上的 `@`/`@@` 必须走这条路。** 拿中心那两根键去算四面体
            // 宇称是错的:换一端起笔是两次对换(偶置换),而两根键只有一次对换,
            // 于是分子被写反 —— 实测 `NC(Br)=[C@]=C(O)F` 先前写成
            // `[C@@](=C(O)F)=C(N)Br`,外部实现说那是另一个分子。
            let on_allene_centre = allene_ends(&self.mol, atom).is_some();
            if rec.tag == ChiralTag::Allene || (on_allene_centre && rec.tag.is_tetrahedral()) {
                let literal = match rec.tag {
                    ChiralTag::Ccw => 1,
                    ChiralTag::Cw => 2,
                    _ => self.mol.atoms()[atom as usize].stereo_perm,
                };
                let perm = if on_allene_centre {
                    allene_renumber(
                        &self.mol,
                        atom,
                        literal,
                        &|a| self.written_bond_order(a),
                        &stored_order_at(&self.mol),
                    )
                    .unwrap_or(0)
                } else {
                    // 不是丙二烯中心的 `@AL`(比如标记写在端碳上):表达不出来,
                    // 序号归 0,写出侧整个丢掉。丢掉是老实的。
                    0
                };
                if let Some(a) = self.mol.atom_mut(atom) {
                    a.chiral_tag = ChiralTag::Allene;
                    a.stereo_perm = perm;
                }
                continue;
            }

            if !rec.tag.is_tetrahedral() {
                continue;
            }

            let mut odd = permutation_is_odd(&written, &stored).unwrap_or(false);

            if stored.len() == 3 {
                // 此刻价键尚未计算,所以"是否有第四个价位"只能看写出来的氢数
                let has_fourth_valence = rec.num_explicit_hs == 1;
                // 不饱和:任一键的数值键级 > 1(芳香键的 1.5 也算)
                let unsaturated = self
                    .mol
                    .bonds()
                    .iter()
                    .filter(|b| b.other_end(atom).is_some())
                    .any(|b| b.order.as_double() > 1.0);

                if (rec.is_smiles_start && rec.num_explicit_hs == 1)
                    || (!has_fourth_valence && ring_bonds.len() == 1 && !unsaturated)
                {
                    odd = !odd;
                }
            }

            if odd {
                if let Some(a) = self.mol.atom_mut(atom) {
                    a.chiral_tag = rec.tag.inverted();
                }
            }
        }
    }
}

/// 芳香键的标志位与键级 AROMATIC 始终同步。
fn mark_aromatic(bd: &mut BondData) {
    if bd.order == BondOrder::Aromatic {
        bd.flags.insert(omgkit_core::BondFlags::AROMATIC);
    }
}

/// 某原子的键在**存储序**下的顺序 —— 按键下标递增。
///
/// 与 `MolBuilder::neighbors` 给出的顺序相同(半边表是尾插),这里显式写出来
/// 是因为解析途中要按下标扫。
fn stored_bond_order(mol: &MolBuilder, atom: u32) -> Vec<u32> {
    mol.bonds()
        .iter()
        .enumerate()
        .filter(|(_, b)| b.other_end(atom).is_some())
        .map(|(i, _)| i as u32)
        .collect()
}

/// 丙二烯型轴手性的四个配体之一:一根键,或者某个端原子上的一个氢。
///
/// 氢要连端原子一起记 —— 两端各可能有一个,它们是**不同的**配体。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AlleneLigand {
    Bond(u32),
    Hydrogen(u32),
}

/// `centre` 是不是丙二烯型轴手性的中心:恰好两个邻居,两根都是双键。
///
/// 只认这一档 —— 更长的累积双键(`C=C=[C]=C=C`)不在其内:那时两端不是端原子,
/// 下面 [`allene_ligands`] 会因为"这一端凑不出两个配体"而返回 `None`。
fn allene_ends(mol: &MolBuilder, centre: u32) -> Option<[u32; 2]> {
    let bonds = stored_bond_order(mol, centre);
    let [a, b] = <[u32; 2]>::try_from(bonds).ok()?;
    let mut ends = [0u32; 2];
    for (k, bond) in [a, b].into_iter().enumerate() {
        let bd = &mol.bonds()[bond as usize];
        if bd.order != BondOrder::Double {
            return None;
        }
        ends[k] = bd.other_end(centre)?;
    }
    (ends[0] != ends[1]).then_some(ends)
}

/// 端原子上有几个氢。
///
/// **两侧用同一条规则。** 方括号原子的氢数写死在 `num_explicit_hs` 里;裸写形式
/// 的要按价键算 —— 解析途中 `num_implicit_hs` 还没填,只能这么算,而写出侧
/// 也走同一条(`hs_survive_without_brackets` 早就这么做了),两处才不会分家。
/// 带电荷、自由基、同位素的原子算不出来,那时整个标记丢掉。
fn allene_end_hydrogens(mol: &MolBuilder, end: u32) -> Option<u8> {
    let a = mol.atoms()[end as usize];
    if a.flags.contains(AtomFlags::NO_IMPLICIT) {
        Some(a.num_explicit_hs)
    } else {
        omgkit_core::valence::implicit_hs_for_bare_form(mol, end)
    }
}

/// 丙二烯型轴手性的四个配体,按 `order_at` 给出的那种顺序。
///
/// `order_at(a)` 要给出原子 `a` 的键顺序,以及"自身位置"(氢落在那儿 ——
/// 紧跟前驱原子之后、环闭合之前,与四面体、配位几何是同一个槽)。
/// 解析侧传书写序,写出侧传输出序,存储序两边都传按键下标递增的那个。
/// **规则只有这一份**,两侧只是喂进不同的顺序。
///
/// 顺序:两端按中心的键顺序排,端内按该端自己的键顺序排(不含通往中心的
/// 那根键)。任一端凑不出恰好两个配体时返回 `None` —— 那时要么不是丙二烯
/// 中心,要么那一端两个配体相同(`=CH2`),都不是立体中心。
fn allene_ligands(
    mol: &MolBuilder,
    centre: u32,
    order_at: &dyn Fn(u32) -> (Vec<u32>, usize),
) -> Option<[AlleneLigand; 4]> {
    let ends = allene_ends(mol, centre)?;
    let (centre_bonds, _) = order_at(centre);
    let mut out = Vec::with_capacity(4);
    for chain in centre_bonds {
        let end = mol.bonds()[chain as usize].other_end(centre)?;
        if !ends.contains(&end) {
            return None;
        }
        let hs = allene_end_hydrogens(mol, end)?;
        if hs > 1 {
            return None; // 两个氢在同一端,那一端的两个配体相同
        }
        let (bonds, self_pos) = order_at(end);
        let mut ligands: Vec<AlleneLigand> = bonds.into_iter().map(AlleneLigand::Bond).collect();
        if hs == 1 {
            ligands.insert(self_pos, AlleneLigand::Hydrogen(end));
        }
        ligands.retain(|l| *l != AlleneLigand::Bond(chain));
        if ligands.len() != 2 {
            return None;
        }
        out.extend(ligands);
    }
    <[AlleneLigand; 4]>::try_from(out.as_slice()).ok()
}

/// 存储序下的键顺序,给 [`allene_ligands`] 用。氢落在该端的最前
/// (存储序里没有"前驱原子"可言,约定如此;两侧用同一份就行)。
fn stored_order_at(mol: &MolBuilder) -> impl Fn(u32) -> (Vec<u32>, usize) + '_ {
    |a| (stored_bond_order(mol, a), 0)
}

/// 把丙二烯的排布序号从一种配体顺序换算到另一种。
///
/// 序号只有 1、2 两个值,一次对换即互换 —— 与四面体同理,只是配体来自两端。
/// 序号 0(光秃的 `@AL`)、或者配体凑不齐时返回 `None`:那表示"这个标记
/// 表达不出来",写出侧据此整个丢掉。
fn allene_renumber(
    mol: &MolBuilder,
    centre: u32,
    perm: u8,
    from: &dyn Fn(u32) -> (Vec<u32>, usize),
    to: &dyn Fn(u32) -> (Vec<u32>, usize),
) -> Option<u8> {
    if perm != 1 && perm != 2 {
        return None;
    }
    let a = allene_ligands(mol, centre, from)?;
    let b = allene_ligands(mol, centre, to)?;
    let odd = permutation_is_odd(&a, &b)?;
    Some(if odd { 3 - perm } else { perm })
}

/// 配位几何里那个"不是键"的顶点在配体序列里的占位标识。
///
/// 取一个不可能出现的键下标即可 —— 它只需要在同一个配体序列里唯一。
const VACANT_LIGAND: u32 = u32::MAX;

/// 配位几何(`@SP`/`@TB`/`@OH`)的配体序列,**存储序**,而且补上那个
/// "不是键"的顶点。
///
/// 方括号里的氢、或者一个空的配位位置,也占多面体的一个顶点,而它不在键序列
/// 里。实测 RDKit 2025.09.2:它落在**"自身位置"** —— 紧跟前驱原子之后、
/// 环闭合之前,与四面体里隐式氢占的是同一个槽。三类几何、三种上下文
/// (居首 / 前面有原子 / 带环闭合)量出来的都是这一条,别的插入位置全都不合。
///
/// 存储序里没有"前驱原子"可言,所以约定把它排在**最前**。约定本身怎么定
/// 无所谓 —— 只要解析与写出用的是同一份,序号就换算得回来。这个函数就是
/// 那"同一份":两侧都从这里取配体序列,想不同步都难。
///
/// 缺两个及以上顶点时返回 `None`。那时至少有两个顶点在 SMILES 里分不开
/// (参照实现会把好几个序号并成同一个分子 —— `[Pt@SP1](N)O` 与
/// `[Pt@SP3](N)O` 是同一个),序号表达不出来,只能整个丢掉标记。
fn coordination_ligands(mol: &MolBuilder, atom: u32, tag: ChiralTag) -> Option<Vec<u32>> {
    let n = omgkit_core::polyhedron::ligand_count(tag)?;
    let mut ligands: Vec<u32> = mol.neighbors(atom).map(|(_, bond)| bond).collect();
    match n.checked_sub(ligands.len())? {
        0 => {}
        1 => ligands.insert(0, VACANT_LIGAND),
        _ => return None,
    }
    Some(ligands)
}

/// `written` → `storage` 置换的宇称。两者不是同一多重集时返回 `None`。
///
/// n ≤ 6,O(n²) 完全够用。
pub(crate) fn permutation_is_odd<T: PartialEq>(written: &[T], storage: &[T]) -> Option<bool> {
    let n = written.len();
    if n != storage.len() {
        return None;
    }
    let mut used = vec![false; n];
    let mut perm = Vec::with_capacity(n);
    for w in written {
        let j = storage
            .iter()
            .enumerate()
            .position(|(j, s)| !used[j] && s == w)?;
        used[j] = true;
        perm.push(j);
    }
    let mut inversions = 0usize;
    for i in 0..n {
        for j in i + 1..n {
            if perm[i] > perm[j] {
                inversions += 1;
            }
        }
    }
    Some(inversions % 2 == 1)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::ParseErrorKind as K;

    fn parse_ok(s: &str) -> MolBuilder {
        parse(s).unwrap_or_else(|e| panic!("应能解析 {s:?}:\n{}", e.render()))
    }

    // -- 基本形状 --

    #[test]
    fn shapes() {
        for (smi, na, nb) in [
            ("C", 1, 0),
            ("CC", 2, 1),
            ("CCO", 3, 2),
            ("CC(=O)O", 4, 3),
            ("C1CCCCC1", 6, 6),
            ("CCO.CCN", 6, 4),
            ("c1ccccc1", 6, 6),
            ("C1CC2CCC1CC2", 8, 9),
        ] {
            let m = parse_ok(smi);
            assert_eq!((m.num_atoms(), m.num_bonds()), (na, nb), "{smi}");
        }
    }

    #[test]
    fn dot_keeps_one_molecule_with_two_fragments() {
        // `.` 不切分分子,只是断开成键
        let m = parse_ok("CCO.CCN");
        assert_eq!(m.num_atoms(), 6);
        assert_eq!(m.num_bonds(), 4);
    }

    // -- 方括号原子 --

    #[test]
    fn bracket_atom_fields() {
        let m = parse_ok("[13CH4:7]");
        let a = m.atoms()[0];
        assert_eq!(a.atomic_num, 6);
        assert_eq!(a.isotope, 13);
        assert_eq!(a.num_explicit_hs, 4);
        assert_eq!(a.atom_map, 7);
        assert!(a.flags.contains(AtomFlags::NO_IMPLICIT));
    }

    #[test]
    fn charge_forms_are_equivalent() {
        assert_eq!(parse_ok("[Fe+2]").atoms()[0].formal_charge, 2);
        assert_eq!(parse_ok("[Fe++]").atoms()[0].formal_charge, 2);
        assert_eq!(parse_ok("[O-]").atoms()[0].formal_charge, -1);
        assert_eq!(parse_ok("[O-2]").atoms()[0].formal_charge, -2);
        assert_eq!(parse_ok("[O--]").atoms()[0].formal_charge, -2);
    }

    #[test]
    fn wildcard_is_atomic_number_zero() {
        assert_eq!(parse_ok("*").atoms()[0].atomic_num, 0);
        assert_eq!(parse_ok("[*:1]").atoms()[0].atom_map, 1);
    }

    #[test]
    fn two_char_element_beats_one_char() {
        // Cl 必须先于 C 匹配,否则会读成 C 再撞上 l
        let m = parse_ok("ClCCBr");
        let z: Vec<u8> = m.atoms().iter().map(|a| a.atomic_num).collect();
        assert_eq!(z, vec![17, 6, 6, 35]);
    }

    #[test]
    fn bracket_greedy_symbol_backtracks() {
        // `[C@]` 里 `C@` 不是元素,必须退回单字符 C
        assert_eq!(parse_ok("[C@](N)(O)(F)Cl").atoms()[0].atomic_num, 6);
        assert_eq!(parse_ok("[Cl-]").atoms()[0].atomic_num, 17);
    }

    // -- 芳香 --

    #[test]
    fn lowercase_atoms_are_flagged_aromatic() {
        let m = parse_ok("c1ccccc1");
        assert!(m
            .atoms()
            .iter()
            .all(|a| a.flags.contains(AtomFlags::AROMATIC)));
        assert!(m.bonds().iter().all(|b| b.order == BondOrder::Aromatic));
    }

    #[test]
    fn default_bond_is_single_when_either_end_is_not_aromatic() {
        // N→c 是单键,即使 c 芳香
        let m = parse_ok("[O-][N+](=O)c1ccccc1");
        assert_eq!(m.bonds()[2].order, BondOrder::Single);
    }

    #[test]
    fn direction_symbols_do_not_force_single_bond() {
        // `/` 是纯方向标记;两端芳香时键级仍是芳香
        let m = parse_ok("Cc1cs/c(=N\\C)/n1");
        assert!(
            m.bonds()
                .iter()
                .any(|b| b.order == BondOrder::Aromatic && b.direction != BondDirection::None),
            "应存在既芳香又带方向的键"
        );
    }

    #[test]
    fn double_backslash_is_one_direction_bond() {
        // `\\` 等价于 `\`
        let a = parse_ok(r"F/C=C\F");
        let b = parse_ok(r"F/C=C\\F");
        assert_eq!(a.num_atoms(), b.num_atoms());
        assert_eq!(a.num_bonds(), b.num_bonds());
        assert_eq!(a.bonds()[2].direction, b.bonds()[2].direction);
    }

    // -- 环闭合 --

    #[test]
    fn ring_bond_is_appended_last_with_closer_first() {
        let m = parse_ok("C1CCCCC1");
        let last = m.bonds()[5];
        assert_eq!(
            (last.begin, last.end),
            (5, 0),
            "环键端点应为 (闭合原子, 开环原子)"
        );
    }

    #[test]
    fn ring_bonds_sort_by_ring_number() {
        // ring2 先开先闭,但 ring1 的键要排在前面
        let m = parse_ok("C2CC2C1CC1");
        assert_eq!(m.num_bonds(), 7, "5 条链键 + 2 条环键");
        let ring: Vec<(u32, u32)> = m.bonds()[m.num_bonds() - 2..]
            .iter()
            .map(|b| (b.begin, b.end))
            .collect();
        assert_eq!(ring, vec![(5, 3), (2, 0)]);
    }

    #[test]
    fn explicit_order_at_ring_open_swaps_endpoints() {
        assert_eq!(
            (
                parse_ok("C1CCCCC1").bonds()[5].begin,
                parse_ok("C1CCCCC1").bonds()[5].end
            ),
            (5, 0)
        );
        let m = parse_ok("C=1CCCCC1");
        assert_eq!((m.bonds()[5].begin, m.bonds()[5].end), (0, 5));
        // 方向符号不触发交换
        let m = parse_ok("C/1CCCCC1");
        assert_eq!((m.bonds()[5].begin, m.bonds()[5].end), (5, 0));
    }

    #[test]
    fn multi_digit_ring_numbers() {
        assert_eq!(parse_ok("C%10CCCCC%10").num_bonds(), 6);
        assert_eq!(parse_ok("C%(123)CCCCC%(123)").num_bonds(), 6);
    }

    // -- 错误:位置必须精确 --

    fn err_at(smi: &str) -> ParseError {
        parse(smi).expect_err(&format!("{smi:?} 应当解析失败"))
    }

    #[test]
    fn unclosed_ring_points_at_the_digit() {
        let e = err_at("C1CC");
        assert_eq!(e.kind, K::UnclosedRingBond(1));
        assert_eq!(e.pos, 1, "应指向环标号所在位置");
        assert!(e.render().contains('^'));
    }

    #[test]
    fn ring_to_self() {
        assert_eq!(err_at("C11").kind, K::RingBondToSelf(1));
    }

    #[test]
    fn unbalanced_parens() {
        assert_eq!(err_at("CC(").kind, K::UnbalancedParen);
        assert_eq!(err_at("CC)").kind, K::UnbalancedParen);
    }

    #[test]
    fn empty_branch() {
        assert_eq!(err_at("CC()C").kind, K::EmptyBranch);
    }

    #[test]
    fn dangling_bond() {
        assert_eq!(err_at("C=").kind, K::DanglingBond);
        assert_eq!(err_at("=C").kind, K::DanglingBond);
    }

    #[test]
    fn unknown_element() {
        assert!(matches!(err_at("[Xx]").kind, K::UnknownElement(_)));
    }

    #[test]
    fn unclosed_bracket() {
        assert_eq!(err_at("[C").kind, K::UnexpectedEnd);
    }

    #[test]
    fn empty_input() {
        assert_eq!(err_at("").kind, K::Empty);
    }

    #[test]
    fn error_render_puts_caret_under_the_right_column() {
        let e = err_at("CCC1CC");
        let rendered = e.render();
        let caret_line = rendered.lines().nth(1).unwrap();
        assert_eq!(caret_line.find('^'), Some(e.pos), "插字号列号应等于 pos");
    }

    // -- 配位键 --

    /// `->` 与 `<-` 是同一条键的两种写法,区别只在端点朝向:
    /// **begin 端提供电子对**。
    #[test]
    fn dative_bond_direction_follows_the_arrow() {
        let m = parse_ok("N->[Cu]");
        let b = m.bonds()[0];
        assert_eq!(
            (b.begin, b.end, b.order),
            (0, 1, BondOrder::Dative),
            "N->[Cu]:给电子的 N 应在 begin"
        );

        let m = parse_ok("[Cu]<-N");
        let b = m.bonds()[0];
        assert_eq!(
            (b.begin, b.end, b.order),
            (1, 0, BondOrder::Dative),
            "[Cu]<-N:给电子的 N 是后写的那个,端点要对调"
        );
    }

    /// 单键的 `-` 不能被 `->` 抢走。
    #[test]
    fn single_bond_dash_is_not_swallowed_by_dative() {
        let m = parse_ok("C-C");
        assert_eq!(m.bonds()[0].order, BondOrder::Single);
        // `-` 后面接 `->` 是两个键符号连写,应报悬空键
        assert_eq!(err_at("N-->[Cu]").kind, K::DanglingBond);
    }

    /// 孤立的 `<` 不是合法符号。
    #[test]
    fn lone_angle_bracket_is_an_error() {
        assert_eq!(err_at("C<C").kind, K::UnexpectedChar('<'));
    }

    /// 配位键也能当环闭合键。朝向仍由箭头决定,且**定了键级的那一端**说了算。
    ///
    /// 端点以集合比对:环键统一追加到键表末尾,下标顺序与书写顺序本就不同。
    #[test]
    fn dative_ring_closures() {
        for (smi, expect) in [
            // 开环端写 `->`:开环原子给电子
            ("N->1CCCCC1", &[(0u32, 5u32)][..]),
            // 开环端写 `<-`:开环原子收电子,对方给
            ("[Cu]<-1CCCC1", &[(4, 0)][..]),
            // 两端都定了键级时,以开环端为准
            ("N->1CCCCC<-1", &[(0, 5)][..]),
            // 开环端只写了环标号,没定键级,于是听闭合端的;
            // 另有一条链上的配位键 `[Cu]<-N`
            ("[Cu]1<-NCCN->1", &[(1, 0), (4, 0)][..]),
        ] {
            let m = parse_ok(smi);
            let mut got: Vec<(u32, u32)> = m
                .bonds()
                .iter()
                .filter(|b| b.order == BondOrder::Dative)
                .map(|b| (b.begin, b.end))
                .collect();
            got.sort_unstable();
            let mut want = expect.to_vec();
            want.sort_unstable();
            assert_eq!(got, want, "{smi}");
        }
    }

    // -- 非四面体立体 --

    /// `@SP` / `@TB` / `@OH` 必须保留**几何类别**和**类内排列序号**。
    /// 只留一个"其它"标记的话,序号丢失,写出时就还原不回去了。
    #[test]
    fn coordination_geometry_keeps_class_and_permutation() {
        for (smi, tag, perm) in [
            ("[Pt@SP1](Cl)(Cl)(N)N", ChiralTag::SquarePlanar, 1u8),
            ("[Pt@SP3](Cl)(Cl)(N)N", ChiralTag::SquarePlanar, 3),
            ("F[P@TB15](Cl)(Br)(I)S", ChiralTag::TrigonalBipyramidal, 15),
            ("C[Co@OH25](N)(O)(S)(P)Cl", ChiralTag::Octahedral, 25),
        ] {
            let m = parse_ok(smi);
            let a = m
                .atoms()
                .iter()
                .find(|a| a.chiral_tag != ChiralTag::Unspecified)
                .unwrap_or_else(|| panic!("{smi}:应有立体标记"));
            assert_eq!((a.chiral_tag, a.stereo_perm), (tag, perm), "{smi}");
        }
    }

    /// 四面体的排列由标记自身表达,序号保持 0 —— 两处都记就会有一致性隐患。
    #[test]
    fn tetrahedral_leaves_permutation_zero() {
        for (smi, tag) in [
            ("[C@](N)(O)(F)Cl", ChiralTag::Ccw),
            ("[C@@](N)(O)(F)Cl", ChiralTag::Cw),
            ("[C@TH1](N)(O)(F)Cl", ChiralTag::Ccw),
            ("[C@TH2](N)(O)(F)Cl", ChiralTag::Cw),
        ] {
            let a = parse_ok(smi).atoms()[0];
            assert_eq!((a.chiral_tag, a.stereo_perm), (tag, 0), "{smi}");
        }
    }

    /// 丙二烯的 `@AL` 说的是一根**轴**,不是配位中心:它归一到四个配体
    /// (累积双键两端各两个)的存储序,不走多面体排列表那条路。
    #[test]
    fn allene_is_not_a_coordination_geometry() {
        // 标记落在**端碳**上 —— 它只有一根双键,不是丙二烯中心,这个标记
        // 表达不出来:类别留着,序号归 0,写出侧整个丢掉。
        // 外部实现同样拒收(Indigo:“chirality on atom 1 makes no sense”)。
        let m = parse_ok("N[C@AL1]=C=C(O)F");
        assert_eq!(m.atoms()[1].chiral_tag, ChiralTag::Allene);
        assert_eq!(m.atoms()[1].stereo_perm, 0);

        // 落在中心上才成立,而且归一到**存储序**:这里书写序与存储序一致,
        // 序号原样保留。
        let m = parse_ok("NC(Br)=[C@AL1]=C(O)F");
        assert_eq!(m.atoms()[3].chiral_tag, ChiralTag::Allene);
        assert_eq!(m.atoms()[3].stereo_perm, 1);

        // 把 `@` / `@@` 写在中心上说的是同一件事,归到同一个表示。
        for (smi, want) in [("NC(Br)=[C@]=C(O)F", 1), ("NC(Br)=[C@@]=C(O)F", 2)] {
            let m = parse_ok(smi);
            assert_eq!(m.atoms()[3].chiral_tag, ChiralTag::Allene, "{smi}");
            assert_eq!(m.atoms()[3].stereo_perm, want, "{smi}");
        }
    }

    /// 排列序号**归一到存储序**;配体数与该几何对不上时原样保管。
    ///
    /// 序号的含义相对"配体按什么顺序列出",而环闭合键是闭合那一刻才建的、
    /// 排在存储序末尾 —— 书写序与存储序就此岔开。不归一的话,写出侧按存储序
    /// 解读这个数字,写出来的是另一个分子。
    #[test]
    fn coordination_permutation_is_normalised_to_storage_order() {
        // 环闭合键被追加到键表末尾,故存储序 ≠ 书写序:字面值 5 归一成 11
        let m = parse_ok("[Co@OH5]1(N)(O)(S)(P)CCC1");
        let a = m.atoms()[0];
        assert_eq!(a.chiral_tag, ChiralTag::Octahedral, "类别要保住");
        assert_eq!(a.stereo_perm, 11, "书写序 ≠ 存储序,序号要跟着换");

        // 六个配体、书写序 = 存储序:归一是恒等
        let m = parse_ok("[Co@OH5](N)(O)(S)(P)(C)F");
        assert_eq!(m.atoms()[0].stereo_perm, 5);

        // 只有五个配体 —— 与八面体对不上,换算不了,原样保管
        let m = parse_ok("[Co@OH5](N)(O)(S)(P)C");
        assert_eq!(m.atoms()[0].stereo_perm, 5, "对不上就原样保管");
    }

    /// 排列序号超出该几何的取值范围时报错。
    ///
    /// 排列数由几何本身决定(SP 3 / TB 20 / OH 30),超出的序号不是"暂时
    /// 不认识",而是不存在 —— 放过去等于把一个无法解释的数字存进分子。
    #[test]
    fn out_of_range_stereo_permutation_is_rejected() {
        for (smi, geometry, got, max) in [
            ("[Pt@SP4](Cl)(Cl)(N)N", "SP", 4u32, 3u32),
            ("F[P@TB21](Cl)(Br)(I)S", "TB", 21, 20),
            ("C[Co@OH31](N)(O)(S)(P)Cl", "OH", 31, 30),
            ("[C@TH3](N)(O)(F)Cl", "TH", 3, 2),
            ("N[C@AL3]=C=C(O)F", "AL", 3, 2),
        ] {
            assert_eq!(
                err_at(smi).kind,
                K::StereoPermOutOfRange { geometry, got, max },
                "{smi} 应被拒绝"
            );
        }
        // 0 与省略序号都表示"有这个几何但没指定排列",合法
        assert_eq!(parse_ok("[Pt@SP0](Cl)(Cl)(N)N").atoms()[0].stereo_perm, 0);
        assert_eq!(parse_ok("[Pt@SP](Cl)(Cl)(N)N").atoms()[0].stereo_perm, 0);
        assert_eq!(
            parse_ok("[Pt@SP](Cl)(Cl)(N)N").atoms()[0].chiral_tag,
            ChiralTag::SquarePlanar
        );
    }

    /// 环闭合两端写了互相矛盾的键级 —— **刻意报错**。
    ///
    /// 这是一处有意为之的分歧:同一条键被写了两个不同的键级,只可能是笔误。
    /// 常见做法是取其中一端(通常是开环端)静默放过,那样错误会一路带到
    /// 分子里去。精确报出位置正是手写解析器的意义所在。
    ///
    /// 配位键把这条规则的触发面扩大了:`->` 与 `-` 现在也算冲突。
    #[test]
    fn conflicting_ring_bond_orders_are_rejected_on_purpose() {
        for smi in [
            "C=1CCCCC#1",
            "N-1CCCCC<-1",
            "N=1CCCCC<-1",
            "N->1CCCCC-1",
            "N->1CCCCC=1",
        ] {
            assert_eq!(
                err_at(smi).kind,
                K::ConflictingRingBondOrder(1),
                "{smi} 的两端键级矛盾,应当报错"
            );
        }
        // 两端一致(含"一端 `->` 一端 `<-`"这种朝向相反但键级相同的写法)不算冲突
        for smi in ["N->1CCCCC->1", "N->1CCCCC<-1", "C=1CCCCC=1"] {
            let _ = parse_ok(smi);
        }
    }

    /// 端点对调时方向符号也要跟着翻。
    ///
    /// 一端写箭头、另一端写 `/` 或 `\` 才看得出来 —— 箭头自身不带方向。
    #[test]
    fn dative_ring_closure_flips_the_direction_too() {
        for (smi, begin, end, dir) in [
            ("N/1CCCCC<-1", 0u32, 5u32, BondDirection::UpRight),
            ("N<-1CCCCC/1", 5, 0, BondDirection::UpRight),
            ("N\\1CCCCC<-1", 0, 5, BondDirection::DownRight),
        ] {
            let m = parse_ok(smi);
            let b = m
                .bonds()
                .iter()
                .find(|b| b.order == BondOrder::Dative)
                .unwrap_or_else(|| panic!("{smi}:应有一条配位键"));
            assert_eq!((b.begin, b.end, b.direction), (begin, end, dir), "{smi}");
        }
    }

    // -- .smi 行 --

    #[test]
    fn parse_line_takes_name() {
        let m = parse_line("CCO\tethanol").unwrap();
        assert_eq!(m.num_atoms(), 3);
        assert_eq!(m.name(), Some("ethanol"));

        let m = parse_line("CCO").unwrap();
        assert_eq!(m.name(), None);
    }
}