aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-16 20:23:03 +0200
committeruser <user@clank>2026-08-16 20:23:03 +0200
commit2182ef505c6f12545cf156cca85a629256c28c72 (patch)
tree75a53a5fe4715b65172a33df633dd382e3944528 /src
parentsl0ppty: docs that match the program (diff)
downloadslosh-2182ef505c6f12545cf156cca85a629256c28c72.tar.gz
slosh-2182ef505c6f12545cf156cca85a629256c28c72.tar.xz
slosh-2182ef505c6f12545cf156cca85a629256c28c72.zip
sl0ppty: layouts are `*.layout.kdl`, and each loader says which it got
`config/layout.example.kdl` becomes `config/example.layout.kdl`, and the convention for the files people write is `project.layout.kdl`. Not a new extension, and the reason matters: the syntax genuinely *is* the same hand-rolled KDL subset for both documents -- one parser reads them -- so calling layouts something other than KDL would trade one wrong signal for another and lose every editor that highlights it. The confusion is about *schema*, so the schema goes in the stem: the extension says how to read the file, `.layout` says what is in it. `docker-compose.yml` and `tsconfig.json` settled this convention long ago. **But a filename cannot enforce anything, and that turned out to be the real bug.** `sl0ppty --check` on a layout file printed `ok` and reported "bindings 53" -- it had read a document containing nothing it understood and said so was fine. A config that `include`d a layout linted clean for the same reason. So did `rouded true`. The loader reads a document by *asking it for the keys it knows*, which is fast and clean and never notices what it was given. So it notices now. `KNOWN_TOP` is every node the loader reads, and anything else at the top level is a complaint with a file and line: s.kdl:2: unknown setting: rouded example.layout.kdl:14: this is a layout, not a config: `sl0ppty --layout` reads those sl0ppty: config.kdl: this is a config, not a layout: `gap` is a setting The `layout` node gets its own message because it is the mistake people actually make, and the reverse direction is the same list read backwards -- the layout loader asks `config_is_setting()` rather than keeping a guess of its own, so the two answers cannot disagree about which file is which. A layout whose own tabs are misspelled still gets "layout declares no tabs", which is a different mistake. D2 records it, since it is the decision that two schemas share one syntax. Verified: `tests/test_config.py` gained thirteen checks -- the unknown setting with its line, both wrong-schema messages, the misspelled-tabs case keeping its own, the shipped config linting clean and the shipped layout loading as one, and a drift guard that greps `kdl_child(root, ...)` out of the loader and fails if a setting is missing from `KNOWN_TOP` (verified by removing `gap_aspect` and watching it fail). Full suite green; my own config and the reference config both still lint clean, which is the thing this change could most easily have broken.
Diffstat (limited to 'src')
-rw-r--r--src/app.c14
-rw-r--r--src/config.c80
-rw-r--r--src/config.h5
3 files changed, 99 insertions, 0 deletions
diff --git a/src/app.c b/src/app.c
index ed27be0..6d357d6 100644
--- a/src/app.c
+++ b/src/app.c
@@ -2265,6 +2265,20 @@ bool app_apply_layout(app_t *a, const kdl_node_t *root, bool replace, char *err,
}
if (!made) {
+ /* The other half of telling the two documents apart. A file with `theme` or
+ * `keys` at the top of it is somebody's config, and "declares no tabs" is true
+ * of it in the least useful way -- the same message a layout gets when its own
+ * tabs are misspelled. `config_is_setting` answers from the loader's own list,
+ * so this cannot drift from what a config actually holds. */
+ for (size_t i = 0; i < root->nkids; i++) {
+ const kdl_node_t *n = root->kids[i];
+ if (n && n->name && config_is_setting(n->name)) {
+ if (err)
+ snprintf(err, errcap,
+ "this is a config, not a layout: `%s` is a setting", n->name);
+ return false;
+ }
+ }
if (err) snprintf(err, errcap, "layout declares no tabs");
return false;
}
diff --git a/src/config.c b/src/config.c
index d0cd347..d540dd5 100644
--- a/src/config.c
+++ b/src/config.c
@@ -1387,6 +1387,83 @@ const char *config_default_path(void) {
* mistake, and the message says which one you have. */
#define INCLUDE_MAX_DEPTH 8
+/* Every node this file reads at the top level, so that one it does *not* read can
+ * be said out loud. Without this a whole document of the wrong shape -- a layout
+ * handed to `--check`, or `include`d into a config -- passes silently, because a
+ * loader that only asks for what it knows never notices what it was given. A
+ * mistyped setting did the same thing quietly.
+ *
+ * Kept honest by `tests/test_config.py`, which greps this file for the names it
+ * actually asks `kdl_child(root, ...)` for and fails if one is missing here. */
+static const char *const KNOWN_TOP[] = {
+ "include", /* read before the rest, but a name this file understands */
+ "anim_ms",
+ "bell_indicator",
+ "bell_mark",
+ "close_mark",
+ "dim_unfocused",
+ "double_click_ms",
+ "editor",
+ "focus_follows_mouse",
+ "gap",
+ "gap_aspect",
+ "hints",
+ "hover_delay_ms",
+ "in_band_shaders",
+ "keep_dead",
+ "keys",
+ "min_mark",
+ "min_pane",
+ "min_split",
+ "modal_scrim",
+ "newtab_mark",
+ "padding",
+ "pane_buttons",
+ "rounded",
+ "scroll_lines",
+ "shader_dir",
+ "shaders",
+ "shell",
+ "states",
+ "status_bar",
+ "status_line",
+ "status_pad",
+ "theme",
+ "title_align",
+ "title_inset",
+ "toast_ms",
+ "version_banner",
+ "zoom_mark",
+ "zoom_on_mark",
+};
+
+/* Whether a name is one of this file's settings. Public so that the *layout* loader
+ * can tell somebody they handed it a config -- the two documents share a syntax, so
+ * telling them apart has to come from one list rather than two guesses. */
+bool config_is_setting(const char *name) {
+ if (!name) return false;
+ for (size_t i = 0; i < sizeof KNOWN_TOP / sizeof *KNOWN_TOP; i++)
+ if (strcmp(name, KNOWN_TOP[i]) == 0) return true;
+ return false;
+}
+
+/* Named for what a stray node probably *is* rather than only for what it is not: a
+ * `layout` at the top of a document is somebody's session file, and the mistake
+ * worth naming is the one people make. */
+static void complain_unknown_top(config_t *c, const kdl_node_t *root, char *err,
+ size_t errcap) {
+ for (size_t i = 0; i < root->nkids; i++) {
+ const kdl_node_t *n = root->kids[i];
+ if (!n || !n->name) continue;
+ if (config_is_setting(n->name)) continue;
+ if (strcmp(n->name, "layout") == 0)
+ complain(c, err, errcap, n->line,
+ "this is a layout, not a config: `sl0ppty --layout` reads those");
+ else
+ complain(c, err, errcap, n->line, "unknown setting: %s", n->name);
+ }
+}
+
static bool load_into(config_t *c, const char *path, int depth, char *err,
size_t errcap);
@@ -1507,6 +1584,9 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
/* After the includes, because each of those set it to its own file while it
* was being read. From here the complaints belong to this file. */
c->loading = path;
+ /* ...which includes "I have no idea what this document is", so it has to be
+ * said after the file is known and not before. */
+ complain_unknown_top(c, root, err, errcap);
c->gap = (uint16_t)kdl_arg_int(kdl_child(root, "gap"), 0, c->gap);
c->gap_aspect =
diff --git a/src/config.h b/src/config.h
index 16c4218..365559c 100644
--- a/src/config.h
+++ b/src/config.h
@@ -420,6 +420,11 @@ size_t config_parse_chain_file(const char *path, color_t default_color,
expr_prog_t **exprs, size_t *nexprs,
char *err, size_t errcap);
+/* Whether a name is one of the config's own settings. For the layout loader, which
+ * shares this syntax and needs to tell a config apart from a session file -- one
+ * list, so the two answers cannot disagree. */
+bool config_is_setting(const char *name);
+
/* What a chord does after the leader. Direct bindings answer here too, so
* pressing the leader first never makes a binding stop working. */
action_t config_lookup(const config_t *c, int key, uint16_t mods);