From 76ac66fc8c9f0c8e5689f88fcd9675f4e0fe8905 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Apr 2025 20:36:51 +0530 Subject: [PATCH] Implement various filesystem utility functions in C so they can be used in the launcher --- kitty/data-types.c | 39 ++++++++ kitty/launcher/utils.h | 194 +++++++++++++++++++++++++++++++++++++++ kitty_tests/datatypes.py | 56 +++++++++++ 3 files changed, 289 insertions(+) create mode 100644 kitty/launcher/utils.h diff --git a/kitty/data-types.c b/kitty/data-types.c index a9abb8540..285abba50 100644 --- a/kitty/data-types.c +++ b/kitty/data-types.c @@ -13,6 +13,7 @@ #endif #include "char-props.h" +#include "launcher/utils.h" #include "line.h" #include "charsets.h" #include "base64.h" @@ -635,13 +636,50 @@ py_char_props_for(PyObject *self UNUSED, PyObject *ch) { #undef B } +static PyObject* +expanduser(PyObject *self UNUSED, PyObject *path) { + if (!PyUnicode_Check(path)) { PyErr_SetString(PyExc_TypeError, "path must a string"); return NULL; } + char buf[PATH_MAX + 1]; + expand_tilde(PyUnicode_AsUTF8(path), buf, arraysz(buf)); + return PyUnicode_FromString(buf); +} + +static PyObject* +abspath(PyObject *self UNUSED, PyObject *path) { + if (!PyUnicode_Check(path)) { PyErr_SetString(PyExc_TypeError, "path must a string"); return NULL; } + char buf[PATH_MAX + 1]; + lexical_absolute_path(PyUnicode_AsUTF8(path), buf, arraysz(buf)); + return PyUnicode_FromString(buf); +} + +static PyObject* +py_makedirs(PyObject *self UNUSED, PyObject *args) { + int mode = 0755; const char *p; Py_ssize_t sz; + if (!PyArg_ParseTuple(args, "s#|i", &p, &sz, &mode)) return NULL; + RAII_PyObject(b, PyBytes_FromStringAndSize(p, sz)); + if (!makedirs(PyBytes_AS_STRING(b), mode)) { PyErr_SetFromErrno(PyExc_OSError); return NULL; } + Py_RETURN_NONE; +} + +static PyObject* +py_get_config_dir(PyObject *self UNUSED, PyObject *args UNUSED) { + char buf[PATH_MAX]; + if (get_config_dir(buf, PATH_MAX)) return PyUnicode_FromString(buf); + return PyUnicode_FromString(""); +} + + static PyMethodDef module_methods[] = { METHODB(replace_c0_codes_except_nl_space_tab, METH_O), {"wcwidth", (PyCFunction)wcwidth_wrap, METH_O, ""}, + {"expanduser", (PyCFunction)expanduser, METH_O, ""}, + {"abspath", (PyCFunction)abspath, METH_O, ""}, {"expand_ansi_c_escapes", (PyCFunction)expand_ansi_c_escapes, METH_O, ""}, {"get_docs_ref_map", (PyCFunction)get_docs_ref_map, METH_NOARGS, ""}, + {"get_config_dir", (PyCFunction)py_get_config_dir, METH_NOARGS, ""}, {"wcswidth", (PyCFunction)wcswidth_std, METH_O, ""}, {"open_tty", open_tty, METH_VARARGS, ""}, + {"makedirs", py_makedirs, METH_VARARGS, ""}, {"normal_tty", normal_tty, METH_VARARGS, ""}, {"raw_tty", raw_tty, METH_VARARGS, ""}, {"close_tty", close_tty, METH_VARARGS, ""}, @@ -737,6 +775,7 @@ shift_to_first_set_bit(CellAttrs x) { return ans; } + EXPORTED PyMODINIT_FUNC PyInit_fast_data_types(void) { PyObject *m; diff --git a/kitty/launcher/utils.h b/kitty/launcher/utils.h new file mode 100644 index 000000000..9a34c3c4d --- /dev/null +++ b/kitty/launcher/utils.h @@ -0,0 +1,194 @@ +/* + * utils.h + * Copyright (C) 2025 Kovid Goyal + * + * Distributed under terms of the GPL3 license. + */ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +static const char* home = NULL; + +static void +ensure_home_path(void) { + if (home) return; + home = getenv("HOME"); + if (!home || !home[0]) { + struct passwd* pw = getpwuid(geteuid()); + if (pw) home = pw->pw_dir; + } + if (!home || !home[0]) { + fprintf(stderr, "Fatal error: Cannot determine home directory\n"); exit(1); + } +} + +static const char* +home_path_for(const char *username) { + struct passwd* pw = getpwnam(username); + if (pw) return pw->pw_dir; + return NULL; +} + +static inline void +expand_tilde(const char* path, char *ans, size_t ans_sz) { + if (path[0] != '~') { + snprintf(ans, ans_sz, "%s", path); + return; + } + const char *prefix = NULL, *sep = ""; + if (path[1] == '/' || path[1] == '\0') { + // If the path is "~" or "~/something", get the current user's home directory + ensure_home_path(); + prefix = home; + } else { + // If the path is "~user/something", get the specified user's home directory + char* slash = strchr(path, '/'); + if (slash) { + *slash = 0; prefix = home_path_for(path + 1); *slash = '/'; + } else prefix = home_path_for(path + 1); + if (prefix) path = slash ? slash - 1 : "a"; + else { + prefix = ""; + path--; + } + } + // Construct the expanded path + snprintf(ans, ans_sz, "%s%s%s", prefix, sep, path + 1); +} + +static size_t +clean_path(char *path) { + char *write_ptr = path; + char* read_ptr = path; + while (*read_ptr) { + if (read_ptr[0] != '/') { *write_ptr++ = *read_ptr++; continue; } + // we have / + if (read_ptr[1] == '/') { read_ptr++; continue; } // skip one slash of double slash + if (read_ptr[1] != '.') { *write_ptr++ = *read_ptr++; continue; } + // we have /. + if (read_ptr[2] == '/' || !read_ptr[2]) { read_ptr += 2; continue; } // skip /./ + if (read_ptr[2] != '.') { *write_ptr++ = *read_ptr++; continue; } + // we have /.. + if (read_ptr[3] == '/' || !read_ptr[3]) { + read_ptr += 3; + while (write_ptr > path) { + write_ptr--; + if (*write_ptr == '/') break; + } + } else *write_ptr++ = *read_ptr++; + } + // remove trailing slashes + while (write_ptr > path + 1 && *(write_ptr - 1) == '/') write_ptr--; + // Null-terminate the normalized path + *write_ptr++ = '\0'; + return write_ptr - path - 1; +} + +static size_t +lexical_absolute_path(const char* relative, char *output, size_t outsz) { + size_t rlen = strlen(relative); + char *limit = output + outsz; + char* write_ptr = output; // Points to the location to write normalized characters +#define _ensure_space(n) if (write_ptr + n + 1 >= limit) { fprintf(stderr, "Out of buffer space making absolute path for: %s with cwd: %s\n", relative, output); exit(1); } + if (relative[0] != '/') { + if (!getcwd(output, outsz)) { + perror("Getting the current working directory failed with error"); + exit(1); + } + size_t cwdlen = strlen(output); + write_ptr = output + cwdlen; + _ensure_space(cwdlen + rlen + 2); + if (rlen && cwdlen && *(write_ptr - 1) != '/') *(write_ptr++) = '/'; + } else { _ensure_space(rlen + 2); } +#undef _ensure_space + // Append the relative path + memcpy(write_ptr, relative, rlen); + *(write_ptr + rlen) = 0; + size_t ans = clean_path(output); + // Ensure the path is not empty + if (output[0] == '\0') { + output[0] = '/'; output[1] = 0; + ans = 1; + } + return ans; +} + +static bool +makedirs_cleaned(char *path, int mode, struct stat *buffer) { + if (stat(path, buffer) == 0) { + if (S_ISDIR(buffer->st_mode)) return true; + errno = ENOTDIR; + return false; + } + if (errno == ENOTDIR) return false; + char *p = strrchr(path, '/'); + if (p && p > path) { + p[0] = 0; + bool parent_created = makedirs_cleaned(path, mode, buffer); + p[0] = '/'; + if (!parent_created) return false; + } + // Now parent exists + return mkdir(path, mode) == 0; +} + +static bool +makedirs(char *path /* path is modified by this function */, int mode) { + struct stat buffer; + clean_path(path); + return makedirs_cleaned(path, mode, &buffer); +} + +static bool +is_dir_ok_for_config(char *q) { + size_t len = strlen(q); + memcpy(q + len, "/kitty", sizeof("/kitty")); + len += sizeof("/kitty") - 1; + memcpy(q + len, "/kitty.conf", sizeof("/kitty.conf")); + if (access(q, F_OK) != 0) return false; + q[len] = 0; + return access(q, W_OK) == 0; +} + +static inline bool +get_config_dir(char *output, size_t outputsz) { + const char *q; + char buf1[PATH_MAX], buf2[PATH_MAX]; +#define expand(x, dest, sz) { expand_tilde(x, buf1, sizeof(buf1)); lexical_absolute_path(buf1, dest, sz); } + q = getenv("KITTY_CONFIG_DIRECTORY"); if (q && q[0]) { expand(q, output, outputsz); return true; } +#define check_and_ret(x) if (x && x[0]) { expand(x, output, outputsz); if (is_dir_ok_for_config(output)) return true; } + q = getenv("XDG_CONFIG_HOME"); check_and_ret(q); + check_and_ret("~/.config"); +#ifdef __APPLE__ + check_and_ret("~/Library/Preferences"); +#endif + q = getenv("XDG_CONFIG_DIRS"); + if (q && q[0]) { + snprintf(buf2, sizeof(buf2), "%s", q); + char *s, *token = strtok_r(buf2, ":", &s); + while (token) { + check_and_ret(token); + token = strtok_r(NULL, ":", &s); + } + } + q = getenv("XDG_CONFIG_HOME"); + if (!q || !q[0]) q = "~/.config"; + expand(q, buf2, sizeof(buf2)); + snprintf(output, outputsz, "%s/kitty", buf2); + if (makedirs(output, 0755)) return true; + return false; +#undef expand +#undef check_and_ret +} diff --git a/kitty_tests/datatypes.py b/kitty_tests/datatypes.py index 4f739e108..3a220165b 100644 --- a/kitty_tests/datatypes.py +++ b/kitty_tests/datatypes.py @@ -3,6 +3,7 @@ import json import os +import shutil import sys import tempfile @@ -11,8 +12,12 @@ from kitty.fast_data_types import ( Color, HistoryBuf, LineBuf, + abspath, char_props_for, expand_ansi_c_escapes, + expanduser, + get_config_dir, + makedirs, parse_input_from_terminal, replace_c0_codes_except_nl_space_tab, split_into_graphemes, @@ -461,6 +466,57 @@ class TestDataTypes(BaseTest): self.assertTrue(is_ok_to_read_image_file(tf.name, tf.fileno()), fifo) self.ae(sanitize_url_for_dispay_to_user( 'h://a\u0430b.com/El%20Ni%C3%B1o/'), 'h://xn--ab-7kc.com/El NiƱo/') + for x in ('~', '~/', '', '~root', '~root/~', '/~', '/a/b/', '~xx/a', '~~'): + self.assertEqual(os.path.expanduser(x), expanduser(x), x) + for x in ( + '/', '', '/a', '/ab', '/ab/', '/ab/c', 'a', 'ab', 'ab/', 'ab///c', 'ab/././..', '.', '..', '../', './', '../..', '../.', + '/a/../..', '/a/../../', '/a/..', '/ab/../../../cd/.', '///', + ): + self.assertEqual(os.path.abspath(x), abspath(x), repr(x)) + self.assertEqual('/', abspath('//')) + with tempfile.TemporaryDirectory() as tdir: + for x, ex in { + 'a': None, 'a/b/c': None, 'a/..': None, 'a/../a': None, + 'a/f': NotADirectoryError, 'a/f/d': NotADirectoryError, 'a/b/c/f/g': NotADirectoryError, + }.items(): + q = os.path.join(tdir, x) + if ex is None: + makedirs(q) + open(os.path.join(q, 'f'), 'wb').close() + else: + with self.assertRaises(ex, msg=x): + makedirs(q) + saved = {x: os.environ.get(x) for x in 'KITTY_CONFIG_DIRECTORY XDG_CONFIG_DIRS XDG_CONFIG_HOME'.split()} + try: + dot_config = os.path.expanduser('~/.config') + if os.path.exists(dot_config): + shutil.rmtree(dot_config) + with tempfile.TemporaryDirectory() as tdir: + os.makedirs(tdir + '/good/kitty') + open(tdir + '/good/kitty/kitty.conf', 'w').close() + open(tdir + '/f', 'w').close() + for x in ( + (f'KITTY_CONFIG_DIRECTORY={tdir}', f'{tdir}'), + (f'XDG_CONFIG_HOME={tdir}/good', f'{tdir}/good/kitty'), + (f'XDG_CONFIG_DIRS={tdir}:{tdir}/good', f'{tdir}/good/kitty'), + (f'XDG_CONFIG_DIRS={tdir}:{tdir}/bad:{tdir}/f', f'{dot_config}/kitty'), + (f'{dot_config}/kitty',), + ): + for k in saved: + os.environ.pop(k, None) + for e in x[:-1]: + k, v = e.partition('=')[::2] + os.environ[k] = v + self.assertEqual(x[-1], get_config_dir(), str(x)) + finally: + if os.path.exists(dot_config): + shutil.rmtree(dot_config) + for k in saved: + os.environ.pop(k, None) + if saved[k] is not None: + os.environ[k] = saved[k] + + def test_historybuf(self): lb = filled_line_buf()