Internals

A deeper look at the pieces you need if you are working on the C layer, the text pipeline, or the frame loop. The overview has the split and the boot order; this page has the machinery.

The C layer

src/ is deliberately thin. It owns a window, a renderer and an OS interface, and exposes them to Lua. It knows nothing about documents, views, commands or keybindings.

directory holds
src/main.c main() and the bootstrap string
src/core/ window, logger, utils, and the Windows DPI setup in boot.c
src/ui/ the renderer, and its caches
src/fs/ path handling and filesystem operations
src/search/ the C-side substring search used by the find bar
src/api/ the Lua-facing bindings: system.*, renderer.*, core.*
src/lua/ the Lua state, the globals, and lua_run_core

The C11 rule is that the C side has no editor concepts. It cannot grow a bug about a document, because it has no idea what one is. If you find yourself wanting to add “tab” or “selection” to src/, the answer is a binding for something the renderer already knows how to draw.

What Lua gets

Five native modules, from api_load_libs (src/api/api.c:11-22). Three are registered eagerly, two are lazy preloads:

module on it
system poll_event, wait_event, cursor, window (title, mode, focus, minimize, maximize), set_hit_regions, show_confirm_dialog, clipboard, get_time, sleep, chdir, list_dir, absolute_path, get_file_info, exec, popen, fuzzy_match
renderer show_debug, get_size, begin_frame, end_frame, set_clip_rect, draw_rect, draw_text, plus the renderer.font sub-table (load, set_tab_width, get_width, get_height, add_fallback, __gc)
search the C substring search behind doc/search.lua, and fuzzy_match
fs mkdir_all, remove_all, copy_all, stat, list_dir — reached through core.fs
path absolute, join, basename, dirname, ext, stem, split, normalize, is_absolute, and sep as a field — core.fs re-exports the first four and sep; the rest are reachable only as require "path"

There is no core table from the C layer. core is the Lua runtime module (data/core/init.lua), installed by the bootstrap. Hit regions are system.set_hit_regions; the scale factor is the SCALE global.

Plus five globals from lua_setup_globals: VERSION, PLATFORM, SCALE, EXEFILE, ARGS. The bootstrap string then adds PATHSEP and EXEDIR.

SCALE is the display content scale, and it is 1.0 on every non-Windows platform — utils_get_scale() has an #ifdef _WIN32 around the real value and returns a literal 1.0 otherwise. Everything sized in pixels multiplies by it (style.lua does this uniformly: common.round(14 * SCALE) for padding and so on), which is why a theme sets colours and never sizes. On Windows this is a real HiDPI factor; everywhere else it is a hook that is not currently connected to anything.

The renderer has no line primitive. Rects and text only — a rule or a divider is a one-pixel rect. A glyph-coverage check walks a chain of at most 8 font fallbacks (renderer.c:217-230), with a per-fallback baseline offset so metrics differ harmlessly.

The renderer cache

src/ui/renderer_cache.c exists because immediate-mode drawing of a text editor re-issues the same rectangles every frame. It buckets commands into an 80 × 50 grid of 96 px cells, hashes each command into the cells it touches, diffs against the previous frame, and expands changed cells to dirty pixel rects. The command buffer is 512 KB.

Invalidation is explicit, via rencache_invalidate, on a resize or a clip-rect change — not per frame.

This is why core.redraw = true matters. A frame that does not redraw skips the whole draw path, which is the idle-cost story. Any state change a view has cached needs to set it, or the screen goes stale.

The text pipeline

data/core/text/ is where non-ASCII gets decided, and it is three independent pieces that run in that order.

utf8.lua

is_cont(byte) is this a continuation byte
len(text) character count, not byte count
chars(text) an iterator over characters
offset(text, n) byte offset of character n
decode(text, pos) codepoint at pos
encode(cp) one character from a codepoint
sanitize(text) drop or replace anything invalid

Columns are byte offsets, not character indices. This is worth stating plainly because the opposite is the natural assumption. DocView:get_col_x_offset slices with text:sub(1, col - 1) and hands the bytes straight to the font; doc/translate.lua steps by byte while skipping UTF-8 continuation bytes, so a motion never lands mid-codepoint; and get_text, insert and remove are all byte-indexed. The document layer is byte-indexed throughout.

UTF-8 awareness appears in exactly two places: skipping continuation bytes so a position is always on a character boundary, and utf8.sanitize on load.

A column past the end of a line is math.huge and means “the end”, which is why doc:newline-below and doc:duplicate-lines can append without measuring.

sanitize is applied to every line on load. A file with invalid UTF-8 opens rather than failing, and the invalid bytes are dropped — a text editor that refuses to open a file is a worse bug than one that shows it slightly wrong.

bidi.lua

is_rtl_char(cp)
base_dir(text) the paragraph direction — the first strong character wins, defaulting to ltr
has_rtl(text)
visual(text, dir) the visual-order run list for a line

shaper.lua

needs_shaping(s) and shape(s) — letterform joining, the reason Arabic and Indic scripts look like words rather than sequences of letters. Driven by config.shaping_enabled, which defaults on.

style.font, style.code_font and style.big_font each get fallback.ttf and emoji.ttf added as fallbacks if those files are present, which is how a single monospace font renders an emoji without a second font stack per view.

The order, which is the whole point

text.visual() is six lines, and both of its branches matter:

local shaped = (shaping and shaper.needs_shaping(s)) and shaper.shape(s) or s
if dir == "ltr" and not bidi.has_rtl(s) then return shaped end
return bidi.visual(shaped, dir)

Shaping runs first, then bidi. Not the other way round. The shaper produces the joined letterforms in logical order; the bidi pass then reorders the already-shaped runs into visual order. Reversing them would reorder unshaped isolated forms, which is wrong in a way that is obvious the moment you have seen it and invisible in a test.

Bidi is not skipped for a line with no RTL text unless direction is exactly "ltr". "auto" — the default — still calls bidi.visual on every line and lets it decide the base direction per line. The short-circuit is for a document that has explicitly been declared left-to-right, not an optimisation for ordinary text.

Shaping is guarded separately by needs_shaping, so an English or CJK line skips the shaper without any configuration.

The document

data/core/doc/init.lua. A Doc is a lines table, a selection, and two undo stacks.

Undo

self.undo_stack = { idx = 1 }
self.redo_stack = { idx = 1 }
self.clean_change_id = 1

The change id is the undo stack’s index, and dirtiness is clean_change_id ~= undo_stack.idx. There is no separate dirty flag to get out of sync: saving calls clean(), which copies the current index. Undo back to the clean index and the document is clean again, which is the behaviour you want and the reason it is implemented this way.

Consecutive edits merge within config.undo_merge_timeout, so typing is one undo step rather than one per keystroke. raw_insert and raw_remove take the time and decide; the public insert/remove do not expose it.

The three hooks

Doc._before_save = {}   -- fn(doc) before writing to disk
Doc._after_save  = {}   -- fn(doc) after writing
Doc._after_load  = {}   -- fn(doc) after reading

Plain lists, called with ipairs. They are the extension seam plugins prefer over wrapping Doc.save, because unloading is table.remove rather than a restore-everything dance. See plugins.

reset_syntax

Runs on load. Reads the first 128 bytes of line 1, asks syntax.get(filename, header), and resets the highlighter only if the answer actually changed — so reloading a file whose syntax did not change does not throw away the token cache.

The highlighter

data/core/doc/highlighter.lua. Per document, incremental, in its own thread.

first_invalid_line the top of the dirty region
max_wanted_line the bottom of what is visible
lines[i] { text, tokens, state, init_state }

The thread walks outward from what is visible, at most 40 lines per pass before yielding, and only when first_invalid_line > max_wanted_line does it back off and wait. That is why opening a very large file is instant.

A cached line records the state it started in. If the previous line’s end state changes, every line after it is stale, and init_state is how that is detected. Editing line 1 of a file whose line 2 opens a multi-line string re-tokenizes everything below — correct, and also why a large paste near the top of a big file is the one edit that costs a frame.

Syntax highlighting

core.syntax is a registry and core.syntax.tokenizer is a walker; the highlighter above is what drives it. The runtime ships no language definitions. Every language is an extension that calls syntax.add, so with nothing installed every file is plain text and doc:toggle-line-comments has no comment marker to read. The format of a definition is cdin-x’s documentation — adding a syntax definition — and what follows is the half that belongs to the runtime.

add appends; lookup walks the list backwards. The last definition that claims a filename wins. There is no way to insert at a position and no remove, so a definition added from an extension’s init() lasts for the session. The filename is tried before the shebang.

Patterns are tried in order at each position, first match wins, each anchored at the current position. If none match, one character is consumed as normal. A delimited span is { open, close } or { open, close, escape }, and the escape is checked by counting the backslashes before a candidate close and testing for an odd number — which is the case most delimiters get wrong.

The state is one level deep. A delimited span sets a single state, the index of the pattern that opened it, and the next line starts in whatever state the previous one ended in. That is what makes multi-line strings and block comments work at all. It is not a stack: a " inside a block comment does not nest a string. A language that needs nesting needs a different walker, and that is the honest limit of the design.

Whitespace is absorbed. Adjacent tokens merge when they share a type or when the previous one is only whitespace, and merging overwrites the type. The run of spaces before a keyword is tokenized as part of the keyword, so leading whitespace cannot be coloured on its own.

A type with no matching style.syntax key is not uniformly rescued. One of the three draw paths in docview.lua falls back to normal; the other two hand the missing colour to the renderer, which draws opaque white. A typo in a definition can therefore render as ordinary text, as white, or as a partly-coloured line, depending on which span it lands in.

file holds
data/core/syntax/syntax.lua add, get, the backwards lookup
data/core/syntax/tokenizer.lua the walker, the state machine, token merging
data/core/doc/highlighter.lua the per-document cache and its thread
data/core/utils/common.lua match_pattern, shared with the project scan

Views

Everything draws through View:extend(), and there are three methods that matter more than the rest.

function MyView:get_name() end          -- the window title and the pane's tab label
function MyView:update() end            -- layout, scroll, anything cached
function MyView:draw() end              -- draw only; must not change state

draw must not change state. It runs only when core.redraw is set, so a view that mutates something while drawing will not do it consistently — the change happens on the frames that redraw and not on the ones that do not. Layout goes in update.

Keys do not arrive here. data/core/events.lua routes a key press to keymap.on_key_pressed and to nothing else; there is no fallback that hands it to the active view, and View does not define on_key_pressed. So a view responds to a keystroke in exactly two ways: through a command it registered and bound, or through a wrapper on keymap.on_key_pressed. EmptyView implements on_key_pressed and is the exception that proves the rule — it is unreachable, and the four shortcuts it advertises do nothing.

The class test is a separate method, View:is(T), and it walks the metatable chain — which is why "core.views.docview" works as a predicate string (command.add requires the string and gets that class back), and why CommandView:is(DocView) is true: CommandView extends DocView in order to reuse its scrolling and gutter maths. That inheritance is load-bearing, and it is also why every doc:* command stays available while a prompt is open.

Scrolling is the view’s own business. DocView:get_scrollable_size, get_line_screen_position, scroll_to_line, scroll_to_make_visible and get_visible_line_range are the set, and a custom scrollable view needs the same five.

View:extend()

From data/core/utils/object.lua, which is the whole inheritance system:

Object:new()      Object:extend()
Object:is(T)      Object:__tostring() Object:__call(...)

extend returns a subclass with super pointing at the parent. There is no metatable magic beyond that — no __index chains to debug, and X.super.new(self) is the first line of every constructor.

Object:is is the instanceof used everywhere.

core.fs

data/core/fs.lua wraps the C filesystem calls, and it is the module a plugin should use rather than shelling out or guessing at separators:

paths join, basename, dirname, abs, sep
queries exists, is_dir, is_file, stat, list (alias ls), pwd
changes mkdir, rm, touch, copy, rename, move, cd

ext, stem, split, normalize and is_absolute are not re-exported. The native path module has all five and nothing in the tree calls them, so they were dropped from this wrapper rather than left as a second way to do the same thing. A plugin that wants one requires path directly — which is a native module, so it is not part of what this document promises to stay.

join handles the separator; core.fs.sep is whatever the C path module reports. A plugin that builds paths by concatenating "/" works on Linux and quietly produces a second directory level on Windows.

list returns entries with type set to "dir" or "file", which is the whole reason fs.list reports it at all: system.list_dir returns bare names, and a name does not say whether it is a directory. An extension that needs the distinction should use core.fs.list rather than counting on the other one.

core.try and logging

function core.try(fn, ...)   -- returns true on success, false plus a logged error

Installed by core.logging. It is the boundary for anything a user action triggered: a thread body, an event handler, command.perform. command.perform wraps every call in it, which is why a raising command logs rather than taking the frame loop with it. On failure it attaches a de-tabbed debug.traceback to the log item’s .info, which is the only way to see where a plugin broke.

All three log levels are printf-style and append to the same ring buffer, core.log_items, capped at config.max_log_items. They differ only in the status bar:

status bar in the log
core.log an i in style.text yes
core.log_quiet nothing yes
core.error a ! in style.accent yes

Every item records { text, time = os.time(), at = "src:line" }, from debug.getinfo. log_quiet is not “quieter” in the log — it is the same entry without the transient status-bar message, which is what you want for something that happens on every keystroke.

The Lua log and cdin-log.txt are one file. core.log and core.error write core.log_items, which core:open-log shows, and data/core/logging.lua also appends each message — and, for core.try failures, the traceback — to the C logger’s file, cdin-log.txt next to the binary, tagged LUA. The C logger writes its own lines (tagged by level) to the same file, so it reads in the order things happened. CDIN_LUA_LOG=0 turns the Lua half off.

There is no crash handler, and no error.txt is ever written. An uncaught raise propagates to the bootstrap’s xpcall (src/lua/api.c:39-49), which calls cdin_log_fatal → log_fatal → cdin-log.txt, and then main() returns and the process exits normally. It used to write error.txt and save every dirty document to <filename>~ from a core.on_error handler in data/core/lifecycle.lua, but nothing ever called it, so it has been removed. Do not rely on either.

Things that are load-bearing

A short list, because each of these looks like a style choice and is not:

package.path is prepended for EXEDIR/data and appended for the site directory. The editor’s own modules win; extensions extend rather than replace.

Globals are an error. core.runtime.strict is the first thing loaded, so a stray global in a plugin is caught at load rather than when two plugins collide on a name. It fires only on a missing key, so the six globals C sets (ARGS, VERSION, PLATFORM, SCALE, EXEFILE, PATHSEP) bypass it and can be reassigned silently.

config.site_path() is a function, not a field. Your init.lua runs after core.config was required, so a path computed at load time would be fixed before you could change it and the one knob would silently do nothing.

The default keymap is installed before your config, and your config before the plugins. That single ordering is what makes a plugin able to override a key you set and you able to override a key a plugin sets.

A failing plugin never stops startup. Bundled is reported at error level, site is logged and skipped. An editor that refuses to open is a worse bug than a missing feature.

data/core is symlinked into the build and recreated every time. A link made once and then assumed correct is exactly how a stale copy becomes the editor you are running.

Two of the doc:move-to-* commands are overwritten by hand. doc:move-to-previous-char and doc:move-to-next-char collapse a selection before moving, which is why the arrows shrink a selection. The generated versions are still there for the other fourteen translations.

Files

file holds
src/main.c main()
src/lua/api.c the globals and the bootstrap string
src/ui/renderer.c drawing, and the caches
data/core/loop.lua the frame loop and the scheduler
data/core/doc/init.lua the document, undo, the hooks
data/core/doc/highlighter.lua the incremental highlighter
data/core/text/ UTF-8, bidi, shaping
data/core/utils/object.lua extend, is
data/core/fs.lua the filesystem and path layer
data/core/logging.lua core.try and the three levels