Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 10x 10x 4x 4x 6x 6x 1x 1x 1x 260x 260x 260x 257x 226x 226x 226x 260x 12066x 10864x 9664x 9067x 12066x 12066x 233x 260x 1x 1x 1x 212x 212x 213x 213x 213x 212x 212x 1x 1x 1x 45x 45x 45x 45x 45x 5x 45x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 1x 1x 1x 6x 6x 4x 4x 4x 4x 4x 4x 4x 6x 6x 6x 6x 3x 3x 6x 1x 1x 1x 1x 1x 1x 6x 1x 1x 1x 3x 3x 3x 3x 3x 1x 1x 2x 3x 3x 3x | import { isCAS } from '@/utils/typeGuards/common';
import { executeSDQSearch } from '@/helpers/pubchem';
import { Cactus } from '@/utils/Cactus';
import { Logger } from '@/utils/Logger';
import { getErrorMessage } from '@/helpers/exceptions';
const logger = new Logger('smiles');
/**
* @group Helpers
* @groupDescription SMILES structure detection and resolution utilities. These turn a SMILES
* (or `smiles:`/`inchikey:` prefixed) query into a searchable chemical name/CAS by resolving it
* through the NCI Cactus service, with a PubChem SDQ fallback. SMILES notation is not canonical
* (ethanol can be written `CCO`, `OCC`, or `C(C)O`), so it cannot be matched directly — it must
* be resolved to a stable identifier first.
* @source
*/
/** Characters that strongly signal a token is SMILES rather than a name (bonds, branches, brackets). */
const STRONG_SMILES_CHARS = /[=#[\]()@.+\\/]/;
/** A ring-closure digit (a digit immediately following an atom letter), e.g. the `1`s in `c1ccccc1`. */
const RING_CLOSURE = /[A-Za-z]\d/;
/** A token composed entirely of SMILES organic-subset atoms (e.g. `CCO`, `ClCCl`, `c1` aromatics). */
const PURE_ORGANIC_ATOMS = /^(Cl|Br|B|C|N|O|P|S|F|I|b|c|n|o|p|s)+$/;
// SMILES syntax grammar, assembled from atom/bond fragments. It is a structural matcher (a legal
// token sequence), not a full parser — it does not verify ring-closure pairing, valences, or
// delimiter balance. The same fragments drive both whole-string validation and in-text extraction.
const SMILES_BRACKET = String.raw`\[[0-9]*(?:[A-Za-z][a-z]?|\*)[^\]]*\]`;
const SMILES_ATOM = String.raw`(?:${SMILES_BRACKET}|Cl|Br|[BCNOFPSI]|[bcnops]|\*)`;
const SMILES_INNER = String.raw`(?:${SMILES_ATOM}|[=#$:/\\\-]|[()]|%[0-9]{2}|[0-9])`;
const SMILES_ENDING = String.raw`(?:${SMILES_ATOM}|[0-9)])`;
const SMILES_COMPONENT = String.raw`${SMILES_ATOM}(?:${SMILES_INNER}*${SMILES_ENDING})?`;
const SMILES_BODY = String.raw`${SMILES_COMPONENT}(?:\.${SMILES_COMPONENT})*`;
/** Character class spanning every legal SMILES character — bounds the extraction guard. */
const SMILES_CHAR = String.raw`[A-Za-z0-9@+\-\[\]()=#$:/\\.%*]`;
/** "Strong" characters that distinguish a real structure from an ordinary word or name. */
const SMILES_STRONG_CHAR = String.raw`[=#$\[\]()0-9/\\]`;
/** Matches a string that is, in its entirety, a single SMILES structure. */
const SMILES_VALIDATE = new RegExp(`^${SMILES_BODY}$`);
// Extracts SMILES embedded in free text: a leading non-word boundary, a lookahead requiring a
// "strong" character so plain words aren't misread, the structure in a `SMILES` named group, and a
// trailing non-word boundary. Global so all structures in the text are returned.
const SMILES_EXTRACT = new RegExp(
`(?<![A-Za-z0-9])(?=${SMILES_CHAR}*${SMILES_STRONG_CHAR})(?<SMILES>${SMILES_BODY})(?![A-Za-z0-9])`,
'g',
);
/** Upper bound on SMILES length we attempt to resolve, to avoid pathological URLs/inputs. */
const MAX_SMILES_LENGTH = 500;
/**
* The result of resolving a structure (SMILES or InChIKey) to searchable identifiers.
* @category Helpers
* @source
*/
export interface ResolvedStructure {
/** Best human-readable name to feed the supplier search, if one was found. */
name?: string;
/**
* Ranked candidate names to match a supplier's products against, best first (the first is
* {@link name}). A CAS/formula resolves to several (Title + cleaned synonyms) so a product
* is matched by whichever scores best; SMILES resolution sets only {@link name}.
*/
names?: string[];
/** Valid CAS number(s) resolved from the structure, shortest/best first. */
cas?: string[];
/** Canonical standard InChIKey — the structure fingerprint, useful for later verification. */
inchikey?: string;
/** Which resolver produced the usable identifier. */
source?: 'cactus-name' | 'cactus-cas' | 'pubchem-inchikey' | 'pubchem-cas' | 'pubchem-formula';
}
/** The mode parsed from an optional query prefix. */
type StructureMode = 'smiles' | 'inchikey' | 'auto';
/**
* Strips an explicit `smiles:` or `inchikey:` prefix from a query, letting the user force
* structure resolution for otherwise-ambiguous input (e.g. `smiles:CO` instead of carbon monoxide).
* @category Helpers
* @param query - The raw search query, possibly prefixed
* @returns The detected mode and the bare value with the prefix removed
* @example
* ```typescript
* parseStructurePrefix("smiles:CCO") // { mode: "smiles", value: "CCO" }
* parseStructurePrefix("inchikey:LFQSCWFLJHTTHZ-UHFFFAOYSA-N")
* // => { mode: "inchikey", value: "LFQSCWFLJHTTHZ-UHFFFAOYSA-N" }
* parseStructurePrefix("ethanol") // { mode: "auto", value: "ethanol" }
* ```
* @source
*/
export function parseStructurePrefix(query: string): { mode: StructureMode; value: string } {
const match = /^(smiles|inchikey)\s*:\s*(.+)$/i.exec(query.trim());
if (match) {
return { mode: match[1].toLowerCase() as 'smiles' | 'inchikey', value: match[2].trim() };
}
return { mode: 'auto', value: query.trim() };
}
/**
* Lightweight validity check for a SMILES string — verifies the characters are legal and that
* parentheses/brackets are balanced. This is a cheap guard before a network call, not a real
* SMILES parser; chemically-invalid but syntactically-plausible strings still pass.
* @category Helpers
* @param smiles - The candidate SMILES string
* @returns True if the string is plausibly a SMILES, false otherwise
* @example
* ```typescript
* isProbablyValidSmiles("CC(=O)O") // true
* isProbablyValidSmiles("[Na+]") // true (bracketed single atom)
* isProbablyValidSmiles("P") // false (single character — not assumed to be phosphorus)
* isProbablyValidSmiles("CC(=O") // false (unbalanced parenthesis)
* isProbablyValidSmiles("hello!") // false (illegal character)
* ```
* @source
*/
export function isProbablyValidSmiles(smiles: string): boolean {
const value = smiles.trim();
// Reject empty, single-character (a bare `P` is not assumed to be phosphorus), and oversize input.
if (value.length < 2 || value.length > MAX_SMILES_LENGTH) return false;
if (!SMILES_VALIDATE.test(value)) return false;
// The grammar does not enforce delimiter balance — verify parens/brackets close cleanly.
let parens = 0;
let brackets = 0;
for (const char of value) {
if (char === '(') parens++;
else if (char === ')') parens--;
else if (char === '[') brackets++;
else if (char === ']') brackets--;
if (parens < 0 || brackets < 0) return false;
}
return parens === 0 && brackets === 0;
}
/**
* Extracts every SMILES structure embedded in a block of free text, in order of appearance. Each
* candidate must contain a "strong" structural character (a bond, branch, bracket, or digit) so
* ordinary words and chemical names are not misread as structures. Bracketed single atoms such as
* `[Na+]` are returned, but bare single-character atoms are not (a stray `P` is not phosphorus).
* Because SMILES branch parentheses are indistinguishable from prose parentheses, a structure
* wrapped directly in text parentheses may include a trailing `)`.
* @category Helpers
* @param text - Arbitrary text that may contain one or more SMILES structures
* @returns The extracted SMILES strings, in order of appearance (empty if none found)
* @example
* ```typescript
* extractSmiles("The product CC(=O)O forms first.") // ["CC(=O)O"]
* extractSmiles("Compare CC(=O)O and CN1C=NC2=C1C(=O)N(C)C(=O)N2C here.")
* // ["CC(=O)O", "CN1C=NC2=C1C(=O)N(C)C(=O)N2C"]
* extractSmiles("no structures in this sentence") // []
* ```
* @source
*/
export function extractSmiles(text: string): string[] {
const found: string[] = [];
for (const match of text.matchAll(SMILES_EXTRACT)) {
const value = match.groups?.SMILES;
if (value) found.push(value);
}
return found;
}
/**
* Heuristically decides whether a raw query looks like a SMILES structure rather than a chemical
* name or CAS number. Deliberately conservative: it only flags strong structural signals (bond/
* branch/bracket characters, ring-closure digits) or pure organic-atom tokens of 3+ characters, so
* ambiguous short tokens like `CO` and ordinary names stay on the normal name-search path.
* @category Helpers
* @param query - The raw search query
* @returns True if the query should be treated as SMILES
* @example
* ```typescript
* looksLikeSmiles("CCO") // true (pure organic atoms, 3+ chars)
* looksLikeSmiles("CC(=O)O") // true (branch + double bond)
* looksLikeSmiles("c1ccccc1") // true (ring-closure digits)
* looksLikeSmiles("CO") // false (ambiguous: could be carbon monoxide)
* looksLikeSmiles("ethanol") // false (name)
* looksLikeSmiles("64-17-5") // false (CAS)
* ```
* @source
*/
export function looksLikeSmiles(query: string): boolean {
const value = query.trim();
if (!value || /\s/.test(value)) return false;
if (isCAS(value)) return false;
if (!isProbablyValidSmiles(value)) return false;
if (STRONG_SMILES_CHARS.test(value) || RING_CLOSURE.test(value)) return true;
return PURE_ORGANIC_ATOMS.test(value) && value.length >= 3;
}
/**
* Looks up a compound name from an InChIKey via the PubChem SDQ agent.
* @param inchikey - The InChIKey to resolve
* @returns The PubChem compound name, or undefined if not found
* @source
*/
async function nameFromInchikey(inchikey: string): Promise<string | undefined> {
const rows = await executeSDQSearch({
where: { inchikey },
select: ['cmpdname'],
limit: 1,
});
return rows?.[0]?.cmpdname || undefined;
}
/**
* Normalizes a raw Cactus InChIKey response, stripping any `InChIKey=` prefix and whitespace.
* @param raw - The raw InChIKey string from Cactus (may be empty or prefixed)
* @returns The bare InChIKey, or undefined if empty
* @source
*/
function normalizeInchikey(raw: string | undefined): string | undefined {
const value = raw?.replace(/^InChIKey=/i, '').trim();
return value || undefined;
}
/**
* Resolves a SMILES string to searchable identifiers (name, CAS, InChIKey) by querying NCI Cactus
* (which canonicalizes the structure server-side), falling back to a PubChem SDQ InChIKey lookup
* when Cactus yields no usable name or CAS. Returns undefined when no resolver recognizes the
* structure or the input is not a plausible SMILES.
* @category Helpers
* @param smiles - The SMILES string to resolve
* @returns The resolved identifiers, or undefined if nothing resolved
* @example
* ```typescript
* await resolveSmiles("CCO")
* // { name: "ethanol", cas: ["64-17-5"], inchikey: "LFQSCWFLJHTTHZ-UHFFFAOYSA-N", source: "cactus-name" }
* await resolveSmiles("not a structure") // undefined
* ```
* @source
*/
export async function resolveSmiles(smiles: string): Promise<ResolvedStructure | undefined> {
const value = smiles.trim();
if (!isProbablyValidSmiles(value)) return undefined;
try {
const cactus = new Cactus(value);
const [simpleNames, rawCas, rawInchikey] = await Promise.all([
cactus.getSimpleNames(),
cactus.getCAS().catch(() => undefined),
cactus.getStdinchiKey().catch(() => undefined),
]);
const name = simpleNames?.[0];
const cas = (rawCas ?? []).map((entry) => entry.trim()).filter((entry) => isCAS(entry));
const inchikey = normalizeInchikey(rawInchikey);
if (name) {
return { name, cas: cas.length > 0 ? cas : undefined, inchikey, source: 'cactus-name' };
}
if (cas.length > 0) {
return { cas, inchikey, source: 'cactus-cas' };
}
if (inchikey) {
const pubchemName = await nameFromInchikey(inchikey);
if (pubchemName) {
return { name: pubchemName, inchikey, source: 'pubchem-inchikey' };
}
}
return undefined;
} catch (error) {
logger.error(`Error resolving SMILES: ${getErrorMessage(error)}`, { error });
return undefined;
}
}
/**
* Top-level entry that turns any search query into a term the existing supplier search can consume.
* If the query is a structure (via `smiles:`/`inchikey:` prefix or the {@link looksLikeSmiles}
* heuristic) it is resolved to the best searchable identifier (name preferred, CAS fallback) and
* returned alongside the resolved structure metadata. Non-structure queries pass through unchanged.
* Built for the future search wiring; currently invoked manually for testing.
* @category Helpers
* @param query - The raw search query
* @returns `searchTerm` (always safe to pass to the supplier search) and optional `structure`
* @example
* ```typescript
* await resolveQueryForSearch("CCO")
* // { searchTerm: "ethanol", structure: { name: "ethanol", cas: ["64-17-5"], ... } }
* await resolveQueryForSearch("sulfuric acid") // { searchTerm: "sulfuric acid" }
* ```
* @source
*/
export async function resolveQueryForSearch(
query: string,
): Promise<{ searchTerm: string; structure?: ResolvedStructure }> {
const { mode, value } = parseStructurePrefix(query);
if (mode === 'inchikey') {
const name = await nameFromInchikey(value);
return name
? { searchTerm: name, structure: { name, inchikey: value, source: 'pubchem-inchikey' } }
: { searchTerm: value };
}
const treatAsSmiles = mode === 'smiles' || (mode === 'auto' && looksLikeSmiles(value));
if (!treatAsSmiles) {
return { searchTerm: value };
}
const structure = await resolveSmiles(value);
if (!structure) {
return { searchTerm: value };
}
return { searchTerm: structure.name ?? structure.cas?.[0] ?? value, structure };
}
|