Edge Rewrite
// HTMLRewriter · presentation

This page was redesigned at the edge.

Cloudflare fetched the original article and streamed it through HTMLRewriter to apply an entirely new visual system without rebuilding the source page.

// request.cf · coarse context

A page that knows where it met you.

Only coarse request metadata is shown. This demo does not display or persist visitor IP addresses.

Country
US
Cloudflare location
CMH
Connection
HTTP/2
Language
Not provided

Ray ID: a4048b9f3a8519ef

Jump to content

Module:SMILES2IUPAC/Parser

From Wikipedia, the free encyclopedia
-- Module:SMILES2IUPAC/Parser
-- Parses a SMILES string into a plain graph:
--   { atoms = {...}, bonds = {...} }
--
-- PHASE 1 SUPPORT:
--   * organic-subset atoms without brackets:
--       B C N O P S F Cl Br I
--   * single/double/triple bonds:
--       - = #
--   * branches:
--       ( )
--
-- NOT YET SUPPORTED:
--   * bracket atoms [ ... ]
--   * ring closures / %nn
--   * aromatic atoms
--   * / \ : bond symbols
--
-- The parser is responsible only for converting SMILES into
-- a molecular graph. Naming rules belong in separate modules.

local Data = require('Module:SMILES2IUPAC/Data')

local p = {}

-- Bond orders used throughout the graph.
local BOND_ORDER = {
	single = 1,
	double = 2,
	triple = 3,

	['-'] = 1,
	['='] = 2,
	['#'] = 3
}

-- Try to read one atom symbol starting at position i.
-- Returns symbol, newIndex or nil.
local function readAtomSymbol(str, i)
	for _, sym in ipairs(Data.twoLetterSymbols) do
		if str:sub(i, i + #sym - 1) == sym then
			return sym, i + #sym
		end
	end

	local one = str:sub(i, i)

	if Data.organicSubset[one] then
		return one, i + 1
	end

	return nil, i
end

--- Parses a SMILES string.
-- @return graph table on success, or nil, errorMessage on failure.
function p.parse(smiles)
	if type(smiles) ~= 'string' or smiles == '' then
		return nil, 'empty SMILES string'
	end

	-- Strip surrounding whitespace.
	smiles = smiles:match('^%s*(.-)%s*$')

	if smiles == '' then
		return nil, 'empty SMILES string'
	end

	local atoms = {}
	local bonds = {}

	local pos = 1
	local len = #smiles

	-- Atom index to return to after a branch closes.
	local branchStack = {}

	local currentAtom = nil

	-- Bond order waiting to be applied to the next atom.
	local pendingBondOrder = nil

	-- Whether the previous token was an atom.
	-- This lets us validate where bond symbols and branches occur.
	local previousWasAtom = false

	while pos <= len do
		local c = smiles:sub(pos, pos)

		-- Branch opening.
		if c == '(' then
			if not currentAtom then
				return nil, ("branch '(' at position %d has no preceding atom"):format(pos)
			end

			if pendingBondOrder then
				return nil, ("branch '(' at position %d follows a dangling bond symbol"):format(pos)
			end

			table.insert(branchStack, currentAtom)

			previousWasAtom = false
			pos = pos + 1

		-- Branch closing.
		elseif c == ')' then
			if #branchStack == 0 then
				return nil, ("unmatched ')' at position %d"):format(pos)
			end

			if pendingBondOrder then
				return nil, ("branch ends after a dangling bond symbol at position %d"):format(pos)
			end

			currentAtom = table.remove(branchStack)

			previousWasAtom = true
			pos = pos + 1

		-- Explicit bond.
		elseif BOND_ORDER[c] then
			if not currentAtom then
				return nil, ("bond symbol '%s' at position %d has no preceding atom"):format(c, pos)
			end

			if pendingBondOrder then
				return nil, ("two bond symbols in a row at position %d"):format(pos)
			end

			pendingBondOrder = BOND_ORDER[c]

			previousWasAtom = false
			pos = pos + 1

		-- Unsupported stereochemical/aromatic bond symbols.
		elseif c:match('[/\\:]') then
			return nil, ("bond symbol '%s' (stereo/aromatic bond) not supported yet"):format(c)

		-- Unsupported bracket atoms.
		elseif c == '[' then
			return nil, 'bracket atoms ([...]) are not supported yet'

		-- Unsupported ring closures.
		elseif c:match('%d') or c == '%' then
			return nil, 'ring closures are not supported yet'

		-- Unsupported aromatic atoms.
		elseif c:match('[a-z]') then
			return nil, ("aromatic atom '%s' not supported yet"):format(c)

		-- Atom.
		else
			local sym, nextPos = readAtomSymbol(smiles, pos)

			if not sym then
				return nil, ("unrecognised character '%s' at position %d"):format(c, pos)
			end

			-- A new atom may follow another atom directly,
			-- in which case the bond is implicitly single.
			local newIdx = #atoms + 1

			table.insert(atoms, {
				element = sym
			})

			if currentAtom then
				local order = pendingBondOrder or BOND_ORDER.single

				table.insert(bonds, {
					a = currentAtom,
					b = newIdx,
					order = order
				})
			elseif pendingBondOrder then
				-- This should normally be caught by the bond-symbol
				-- validation above, but keep this guard here.
				return nil, 'bond has no starting atom'
			end

			pendingBondOrder = nil
			currentAtom = newIdx
			previousWasAtom = true
			pos = nextPos
		end
	end

	-- A branch must always be closed.
	if #branchStack > 0 then
		return nil, "unmatched '(' — branch never closed"
	end

	-- A bond symbol must always be followed by an atom.
	if pendingBondOrder then
		return nil, 'SMILES ends with a dangling bond symbol'
	end

	if #atoms == 0 then
		return nil, 'no atoms found'
	end

	return {
		atoms = atoms,
		bonds = bonds
	}
end

return p