A small program to browse the LOINC catalogue: 112,405 laboratory and clinical
test codes, in two files of its own design. It links no library — not a
database, not a toolkit — and reads its catalogue with <stdio.h>.
The name is the whole project in one word: LOINC, the catalogue it
browses, and C11, the standard it is written to. The command you type is
still loinc.
This is a study in C. It was written from scratch to learn the language,
one concept at a time, each one compiled and run before the next was started.
The program is real and does its job, but the point of it was always the road
rather than the destination — so the reasoning is written down where it
belongs: in the comments, which explain why far more often than what, and
in STYLE.md, CONVENTIONS.md and VERSIONING.md, which say what the rules
are and how they were arrived at.
The § numbers scattered through the source point into a learning journal
kept alongside the project and not published here. Nothing in the code depends
on it: every comment that cites a section also says what happened in it.
If you are learning C too, read the comments. They are half the repository.
It works two ways. Run it with no arguments and it shows a menu; give it arguments and it does one job, prints the answer and exits — so it fits in a pipeline like any other Unix tool.
$ loinc search glucose | head -3
100746-7 Glucose [Moles/volume] in Mixed venous blood
104597-0 Glucose [Mass/volume] in Venous blood by Glucometer
104598-8 Glucose [Mass/volume] in Arterial blood by GlucometerIt works three ways, in fact. There is also a window:
All three — the menu, the command line and the window — sit on the same engine and share the same object files. None of them is a wrapper around another.
- a C11 compiler (
cc, i.e. gcc or clang) make- optional, for
make gui: GTK 3 —sudo apt install libgtk-3-dev - optional, for
make format:clang-format
The program itself needs no library at all: it stores the catalogue in two
files of its own design and reads them with <stdio.h>. That was a
deliberate trade — it gave up a query language, transactions and a mature
index in exchange for knowing exactly what every byte on disk is for, which
is the better bargain for a program whose purpose is to be understood.
make # build the CLI (debug: -Wall -Wextra -Werror, asan, ubsan)
make run # build, then run from here so data/loinc.dat resolves
make gui # build the GTK 3 program — needs libgtk-3-dev
make run-gui # build the GUI, then run it
make tools # build/flatsearch, the oracle behind the binary search
make check # check the program against the release CSV
make format # reformat sources with clang-format
make clean # delete build/make builds build/loinc, which links nothing: no toolkit, no database
library, just libc. make gui builds build/loinc-gui, the same engine with
GTK 3 on top. Both run on a machine with no display, except the one that
needs a display.
Warnings are errors and the sanitizers are always on: if it builds and runs without a word, it is clean.
make check # or ./tools/check.sh path/to/LoincTableCore.csvThirteen checks against the release CSV: the record count, all 440 class
groupings with their counts, every field of four whole records, and seven
searches. The CSV is parsed by python's csv module rather than by ours — a
checker that shares the suspect's parser cannot catch a bug in it — and
everything after that is awk, sort and cmp over the program's --tabs
output. It reads only; nothing is written.
The catalogue is two files, relative to the current directory:
data/loinc.dat one fixed-size record per item, sorted by COMPONENT
data/loinc.txt the text those records point at
So run the program from loinc-c/, or from any directory with a data/ in
it. There is no configuration file and no option to point it elsewhere yet.
cd loinc-c && ./build/loinc classes # works
cd /tmp && /path/to/loinc-c/build/loinc classes
db_open: cannot open data/loinc.dat and data/loinc.txt # exit 3If data/ exists but the files do not, an empty catalogue is created — which
is why loinc import works on a fresh checkout.
Editing and importing write to those files. To try either safely, work on a copy:
mkdir -p /tmp/loinc-test/data
cp data/loinc.dat data/loinc.txt /tmp/loinc-test/data/
cd /tmp/loinc-test && "$OLDPWD/build/loinc"The two files carry the same generation number in their headers, and the program refuses a pair that does not match — copy both, or neither.
loinc interactive menu
loinc search [field] <term> items whose field starts with <term>
loinc classes [0-4] classes of one type, with their item counts
loinc show <loinc_num> one record, field by field
loinc import [--dry-run] <csv> replace the table with a CSV release
loinc help the summary above (also -h, --help)
loinc --tabs <command> ... TAB-separated output, for cut and awk
Finds every item whose chosen field starts with <term>, case-insensitive,
ordered by COMPONENT. One line per item: the LOINC number and the long common
name. field is optional and comes before the term:
| field | column | example |
|---|---|---|
component (default) |
COMPONENT | loinc search glucose |
number |
LOINC_NUM | loinc search number 14749 |
shortname |
SHORTNAME | loinc search shortname Hgb |
A term with spaces needs quotes, or the shell hands us extra arguments and we report a usage error rather than searching for the first word only:
$ loinc search glucose in blood
Usage: loinc search [field] <term> # exit 2
$ loinc search "glucose in blood" # exit 1, nothing starts with thatLists the distinct CLASS values with the number of items in each. The optional digit is the LOINC class type, and it narrows which classes are listed — it does not filter items:
| digit | meaning | classes |
|---|---|---|
0 (default) |
every class | 440 |
1 |
Laboratory | 74 |
2 |
Clinical | 172 |
3 |
Claims attachments | 25 |
4 |
Surveys | 169 |
$ loinc classes 3
ATTACH 11
ATTACH.AMB 55
ATTACH.CARD 1
…Prints every column of one item, one NAME value line per column.
$ loinc show 14749-6
LOINC_NUM 14749-6
COMPONENT Glucose
PROPERTY SCnc
TIME_ASPCT Pt
SYSTEM Ser/Plas
…Replaces the whole table with a LOINC release in CSV form — the
LoincTableCore.csv of an official download, or any RFC 4180 CSV whose header
names the table's columns in the table's order.
$ loinc import --dry-run Loinc_2.83/LoincTableCore/LoincTableCore.csv
Checking '…/LoincTableCore.csv'...
112406 lines read, 112405 records.
Dry run: nothing was written.
$ loinc import Loinc_2.83/LoincTableCore/LoincTableCore.csv
Importing '…/LoincTableCore.csv'...
112406 lines read, 112405 records.
98269 rows removed, 112405 rows written.Three things worth knowing:
-
The header is checked, not skipped. The table is asked for its own column names and they must match, in order. A file that does not fit is refused before anything is written, naming the column that disagrees:
$ loinc import wrong.csv import_csv: column 4 of the CSV is "TIME_ASPECT", but the table calls it "TIME_ASPCT". Import failed after 1 lines. The table was not changed.
-
It is all one transaction. The delete and every insert live inside a single
BEGIN/COMMIT. Any failure — a malformed line halfway through, a disk error — rolls the whole thing back and leaves the old catalogue exactly as it was. There is no state in which you have half a table. -
--dry-runnever opens a transaction. It parses the file, checks the header, counts the records and reports every malformed line, then stops. Use it on a release you have not seen before. It exits3if the file has any malformed line, even though nothing was written.
The importer does not create the table: it replaces the contents of one that already exists. See Starting from an empty database below.
Fields may contain commas and quotes; "" inside a quoted field means one
". CRLF line endings are handled. A newline inside a field is the one CSV
feature not supported — such a line is reported by number and refused, never
silently mangled.
This repository holds no data: LOINC is redistributed under its own terms, and a release is 26 MB of CSV. Two steps:
-
Download a release from https://loinc.org/downloads/ (free account) and unpack it. You want
LoincTableCore/LoincTableCore.csv. -
Import it:
mkdir -p data ./build/loinc import --dry-run path/to/LoincTableCore.csv # look first ./build/loinc import path/to/LoincTableCore.csv
That is all. There is no table to create and no database to prepare: the
program makes an empty catalogue the first time it opens one that is not
there, and the import fills it. It writes data/loinc.dat (fixed-size
records) and data/loinc.txt (the text they point at).
The importer checks the CSV header against the fifteen columns it expects, in order, and refuses a file that does not match, naming the column that disagrees. Nothing is written until the whole file has parsed.
Data goes to stdout, everything else to stderr. Prompts, banners, counts
and errors never pollute the output, so 2>/dev/null gives you clean data and
a pipeline keeps working.
$ loinc classes 2>/dev/null | sort -k2 -rn | head -3
MICRO 16109
CHEM 10872
DRUG/TOX 10194The columns above are padded so they line up on a screen, which makes them a
picture of a table rather than a table: five class names contain a space
(H&P.SURG PROC among them), so awk '{print $2}' reads the second word of
the name instead of the count.
--tabs, before the command, separates every field with exactly one TAB and
pads nothing:
$ loinc --tabs classes | awk -F'\t' '{s+=$2} END {print s}'
112405
$ loinc --tabs show 14749-6 | awk -F'\t' '$1=="SYSTEM"{print $2}'
Ser/PlasIt applies to search, classes and show. There is no auto-detection on
purpose: a program that behaves differently depending on whether a terminal is
attached is a program whose script works by hand and breaks in cron.
The exit code says what happened, in the shape of grep's:
| code | meaning |
|---|---|
0 |
the command ran and found something |
1 |
nothing matched — an answer, not a failure |
2 |
the command line was wrong (unknown command, missing or extra argument, bad field or class type) |
3 |
the database or a query failed |
if loinc show 14749-6 >/dev/null 2>&1; then
echo "that code exists"
fiNote that loinc … | head -3 exits 141 (128 + 13, SIGPIPE): head closed
the pipe and the kernel stopped us. That is what every Unix tool does, and it
is why command mode never paginates — stopping is the reader's job.
# how many items start with "hemoglobin"
loinc search hemoglobin 2>/dev/null | wc -l
# just the codes
loinc search insulin 2>/dev/null | cut -d' ' -f1
# one field of one record
loinc show 14749-6 2>/dev/null | grep '^SYSTEM'
# show the first hit of a search
loinc show "$(loinc search cholesterol 2>/dev/null | head -1 | cut -d' ' -f1)"
# the laboratory classes, biggest first
loinc classes 1 2>/dev/null | sort -k2 -rn | headRun loinc with no arguments:
=== The LOINC Table Core ===
1) Search items
2) List classes
3) Show a record
4) Edit a record
5) Import a CSV release
0) Quit
Choice:
The menu asks for what command mode takes as arguments — the search field, the class type — and an empty line always keeps the default shown in brackets.
4) Edit a record has no command-mode equivalent, because editing is a
conversation: it walks every field of a record showing the current value, and
an empty line keeps it. Nothing is written until you answer y to the final
confirmation. The record is identified by the LOINC number you loaded it
with, so you can change the LOINC number itself and the row is renamed rather
than lost.
Ctrl-D (EOF) means "quit" everywhere, so the program can be driven by a script:
printf '2\n0\n0\n' | loinc 2>/dev/null # list classes, all types, then quitmake gui && ./build/loinc-gui # or: make run-gui
./build/loinc-gui glucose # open with the answer already thereA third frontend on the same engine — include/db.h does not know it exists,
and no GTK type appears anywhere in the engine. GTK 3 is the only library the
project links anywhere, and only this one program links it.
Pick a field, type a term, press Enter or Search, and the list fills — the
same query as loinc search [field] <term>, wildcards included, out of the
same table of fields the command line parses. Clear empties the list and
the term (and leaves the chosen field alone).
The list shows all seven fields the engine hands over per item. Columns can be dragged wider and clicked to sort; the sort happens in memory, so it is instant on any column — while the file itself is ordered by COMPONENT and nothing else, which is why searching SHORTNAME costs a scan.
A single click selects a row and wakes Edit…; a double click opens it
straight away. The editor is a modal dialog with every field of the record and
a Save that writes nothing until you press it. CLASSTYPE, STATUS and
SCALE_TYP offer their documented values in a drop-down — one you can still
type into, because the catalogue is allowed to contain values we have not
heard of. Closing the dialog puts the list back where you left it, on the row
you were working on.
There is no limit and no paging: the scrollbar is this frontend's head.
The status bar has four sections, so they cannot overwrite each other: which LOINC release this catalogue is, which record is selected, what just happened, and the date and time. The release is derived from the data rather than published with it — the tooltip says so — and an em dash means the catalogue was imported before the program started recording it.
File imports a release — a dry run first, then a question naming the record count, because an import replaces the whole catalogue — shows the logbook, and rewrites the catalogue to reclaim the space edits leave behind. All three run on a worker thread, so the window (and the clock) keep going. Help says who wrote it, and where in the galaxy.
What it does not do yet: filter the classes by type, or draw anything.
One thing worth knowing if you plan to change it: a window cannot be driven by
a pipe, so the automatic gate that guards everything else here does not reach
it. That is the reason the src/ui_*.c files contain no logic at all —
whatever is worth testing lives in the engine, where make check can see it.
The catalogue here is LOINC release 2.83: 112,405 items in 440 classes, loaded
with loinc import from the official LoincTableCore.csv.
Every field is text. There are no types to speak of: a record is bytes,
and include/flat.h says how wide each field is and nothing else. CLASSTYPE
holds the strings "1" to "4" for the same reason the CSV does — nobody
ever converted them, because there is nothing to convert them to.
The widths come from the data and are therefore a promise about a particular
release. CLASS was 24 bytes wide until 2.83 arrived with a 25-character
class name; it is 32 now. When a width changes, sizeof(FlatRecord) changes,
and the header of an older data/loinc.dat no longer matches — the program
refuses the file rather than reading it at the wrong offsets. Re-import after
any such change.
Two historical notes, in case an old catalogue or an old comment turns up. The
catalogue lived in a database until §28 — that engine was removed in §43, and
is still in the history — and the file it lived in had the CSV header imported as
a data row years ago — which is why the count used to be
98,269 rather than 98,268, why classes used to list a class called CLASS,
and why loinc show LOINC_NUM used to print a record made of column names.
loinc import cannot reproduce that: it consumes the header to check it, so
the header can never reach the data. One import, and the wart was gone.
loinc-c/
README.md this file — how to use it
LICENSE MIT
STYLE.md how a line of code is written
CONVENTIONS.md how the program is designed
VERSIONING.md the three version numbers, and which is which
Makefile
include/db.h the engine's public interface: query and edit
include/import.h the engine's public interface: load a CSV release
include/flat.h the fixed-record file format the engine is built on
include/csv.h the RFC 4180 parser
include/fields.h the searchable fields, shared by every frontend
include/logbook.h what the program writes down about itself
include/version.h the program's version, defined once
src/db_flat.c the engine: db.h on two files, and nothing about the user
src/import_flat.c import_csv: a CSV release straight into those files
src/flat.c the format itself: offsets, header, text lookups
src/csv.c src/fields.c src/logbook.c
src/flat_internal.h private to the engine — the real `struct Db`
src/main.c the frontend: menu, command mode, printing
src/ui.h private to the window — its shared `struct Ui`
src/ui_main.c the window: open the catalogue, build it, hand over
src/ui_search.c ask the engine a question src/ui_table.c the list
src/ui_classes.c the classes down the left src/ui_record.c the editor
src/ui_logbook.c read the logbook back src/ui_task.c long jobs
src/ui_menu.c the menu bar, About, and leaving
src/ui_status.c the message line and the clock
src/flatsearch.c the same search over those files, and the oracle that
guards the binary search
data/loinc.dat the records } the catalogue: not in the
data/loinc.txt the text they point at } repository, built by `import`
docs/screenshot.png
The graphical mode searches and shows records; browsing the classes, editing and importing are still menu-and-command-line only, and its search runs on Enter rather than as you type.
Editing is menu-only by design — it is a conversation, so there is no reusable
half. The importer replaces the whole catalogue rather than merging into it.
show scans, because the catalogue is ordered by COMPONENT and nothing else:
a second index is the obvious next thing the storage wants.
MIT — see LICENSE. Deliberately permissive: this is teaching material, and
material you have to ask permission to learn from teaches less. Take the CSV
parser, take the ownership patterns, take whatever is useful.
LOINC itself is a registered trademark of Regenstrief Institute, Inc., and its content is subject to the LOINC licence. None of it is distributed here — this repository contains no data.
