From 7e8eb58e689ec49f04e0f42b60b1e0b7c53b076b Mon Sep 17 00:00:00 2001 From: ptdewey Date: Sun, 27 Jul 2025 15:12:40 -0400 Subject: [PATCH 01/20] refactor: rust rewrite of logging --- .github/workflows/docs.yml | 26 - .gitignore | 5 + Cargo.lock | 1184 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 15 + doc/pendulum-nvim.txt | 267 -------- flake.lock | 26 + flake.nix | 24 + lua/pendulum/csv.lua | 112 ---- lua/pendulum/handlers.lua | 186 +++--- lua/pendulum/init.lua | 1 + src/main.rs | 285 +++++++++ 11 files changed, 1655 insertions(+), 476 deletions(-) delete mode 100644 .github/workflows/docs.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 doc/pendulum-nvim.txt create mode 100644 flake.lock create mode 100644 flake.nix delete mode 100644 lua/pendulum/csv.lua create mode 100644 src/main.rs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index f4ebc7d..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Generate Vimdoc - -on: - push: - branches: - - main - -jobs: - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: panvimdoc - uses: kdheepak/panvimdoc@main - with: - vimdoc: pendulum-nvim - version: "Neovim >= 0.10.0" - demojify: true - treesitter: true - - name: Push changes - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: "doc: auto-generate vimdoc" - commit_user_name: "github-actions[bot]" - commit_user_email: "github-actions[bot]@users.noreply.github.com" - commit_author: "github-actions[bot] " diff --git a/.gitignore b/.gitignore index 77782a4..2a563d4 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,8 @@ luac.out ## Extras remote/pendulum-nvim .luarc.json + + +# Added by cargo + +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..30b5751 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1184 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "csv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +dependencies = [ + "memchr", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pendulum-lsp" +version = "0.1.0" +dependencies = [ + "anyhow", + "csv", + "env_logger", + "log", + "regex", + "serde", + "serde_json", + "tokio", + "tower-lsp", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251471db004e509f4e75a62cca9435365b5ec7bcdff530d612ac7c87c44a792" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-lsp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" +dependencies = [ + "async-trait", + "auto_impl", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower", + "tower-lsp-macros", + "tracing", +] + +[[package]] +name = "tower-lsp-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d502638 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pendulum-lsp" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.98" +csv = "1.3.1" +env_logger = "0.11.8" +log = "0.4.27" +regex = "1.11.1" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.141" +tokio = { version = "1.47.0", features = ["full"] } +tower-lsp = "0.20.0" diff --git a/doc/pendulum-nvim.txt b/doc/pendulum-nvim.txt deleted file mode 100644 index 9cb7d45..0000000 --- a/doc/pendulum-nvim.txt +++ /dev/null @@ -1,267 +0,0 @@ -*pendulum-nvim.txt* For Neovim >= 0.10.0 Last change: 2025 April 02 - -============================================================================== -Table of Contents *pendulum-nvim-table-of-contents* - -1. Pendulum-nvim |pendulum-nvim-pendulum-nvim| - - Motivation |pendulum-nvim-pendulum-nvim-motivation| - - What it Does |pendulum-nvim-pendulum-nvim-what-it-does| - - Installation |pendulum-nvim-pendulum-nvim-installation| - - Configuration |pendulum-nvim-pendulum-nvim-configuration| - - Usage |pendulum-nvim-pendulum-nvim-usage| - - Report Generation |pendulum-nvim-pendulum-nvim-report-generation| - - Future Ideas |pendulum-nvim-pendulum-nvim-future-ideas| -2. Links |pendulum-nvim-links| - -============================================================================== -1. Pendulum-nvim *pendulum-nvim-pendulum-nvim* - -Pendulum is a Neovim plugin designed for tracking time spent on projects within -Neovim. It logs various events like entering and leaving buffers and idle times -into a CSV file, making it easy to analyze your coding activity over time. - -Pendulum also includes a user command that aggregates log information into a -popup report viewable within your editor - - -MOTIVATION *pendulum-nvim-pendulum-nvim-motivation* - -Pendulum was created to offer a privacy-focused alternative to cloud-based time -tracking tools, addressing concerns about data security and ownership. This -"local-first" tool ensures all data stays on the user’s machine, providing -full control and customization without requiring internet access. It’s -designed for developers who prioritize privacy and autonomy but are curious -about how they spend their time. - - -WHAT IT DOES *pendulum-nvim-pendulum-nvim-what-it-does* - -- Automatic Time Tracking: Logs time spent in each file along with the workding directory, file type, project name, and git branch if available. -- Activity Detection: Detects user activity based on cursor movements (on a timer) and buffer switches. -- Customizable Timeout: Configurable timeout to define user inactivity. -- Event Logging: Tracks buffer events and idle periods, writing these to a CSV log for later analysis. -- Report Generation: Generate reports from the log file to quickly view how time was spent on various projects (requires Go installed). - - -INSTALLATION *pendulum-nvim-pendulum-nvim-installation* - -Install Pendulum using your favorite package manager: - - -WITH REPORT GENERATION (REQUIRES GO) - -With lazy.nvim - ->lua - { - "ptdewey/pendulum-nvim", - config = function() - require("pendulum").setup() - end, - } -< - - -WITHOUT REPORT GENERATION - -With lazy.nvim - ->lua - { - "ptdewey/pendulum-nvim", - config = function() - require("pendulum").setup({ - gen_reports = false, - }) - end, - } -< - - -CONFIGURATION *pendulum-nvim-pendulum-nvim-configuration* - -Pendulum can be customized with several options. Here is a table with -configurable options: - - --------------------------------------------------------------------------------------- - Option Description Default - ------------------------- ------------------------------------ ------------------------ - log_file Path to the CSV file where logs $HOME/pendulum-log.csv - should be written - - timeout_len Length of time in seconds to 180 - determine inactivity - - timer_len Interval in seconds at which to 120 - check activity - - gen_reports Generate reports from the log file true - - top_n Number of top entries to include in 5 - the report - - hours_n Number of entries to show in active 10 - hours report - - time_format Use 12/24 hour time format for 12h - active hours report (possible - values: 12h, 24h) - - time_zone Time zone for use in active hour UTC - calculations (format: - America/New_York) - - report_section_excludes Additional filters to be applied to {} - each report section - - report_excludes Show/Hide report sections. e.g {} - branch, directory, file, filetype, - project - --------------------------------------------------------------------------------------- -Default configuration - ->lua - require("pendulum").setup({ - log_file = vim.env.HOME .. "/pendulum-log.csv", - timeout_len = 180, - timer_len = 120, - gen_reports = true, - top_n = 5, - hours_n = 10, - time_format = "12h", - time_zone = "UTC", -- Format "America/New_York" - report_excludes = { - branch = {}, - directory = {}, - file = {}, - filetype = {}, - project = {}, - }, - report_section_excludes = {}, - }) -< - -Example configuration with custom options: (Note: this is not the recommended -configuration, but just shows potential options) - ->lua - require('pendulum').setup({ - log_file = vim.fn.expand("$HOME/Documents/my_custom_log.csv"), - timeout_len = 300, -- 5 minutes - timer_len = 60, -- 1 minute - gen_reports = true, -- Enable report generation (requires Go) - top_n = 10, -- Include top 10 entries in the report - hours_n = 10, - time_format = "12h", - time_zone = "America/New_York", - report_section_excludes = { - "branch", -- Hide `branch` section of the report - -- Other options includes: - -- "directory", - -- "filetype", - -- "file", - -- "project", - }, - report_excludes = { - filetype = { - -- This table controls what to be excluded from `filetype` section - "neo-tree", -- Exclude neo-tree filetype - }, - file = { - -- This table controls what to be excluded from `file` section - "test.py", -- Exclude any test.py - ".*.go", -- Exclude all Go files - } - project = { - -- This table controls what to be excluded from `project` section - "unknown_project" -- Exclude unknown (non-git) projects - }, - directory = { - -- This table controls what to be excluded from `directory` section - }, - branch = { - -- This table controls what to be excluded from `branch` section - }, - }, - }) -< - -**Note**You can use regex to express the matching patterns within -`report_excludes`. - - -USAGE *pendulum-nvim-pendulum-nvim-usage* - -Once configured, Pendulum runs automatically in the background. It logs each -specified event into the CSV file, which includes timestamps, file names, -project names (from Git), and activity states. - -The CSV log file will have the columns: `time`, `active`, `file`, `filetype`, -`cwd`, `project`, and `branch`. - - -REPORT GENERATION *pendulum-nvim-pendulum-nvim-report-generation* - -Pendulum can generate detailed reports from the log file. To use this feature, -you need to have Go installed on your system. The report includes the top -entries based on the time spent on various projects. - -To rebuild the Pendulum binary and generate reports, use the following -commands: - ->vim - :PendulumRebuild - :Pendulum - :PendulumHours -< - -The `:PendulumRebuild` command recompiles the Go binary, and the :Pendulum -command generates the report based on the current log file. I recommend -rebuilding the binary after the plugin is updated. - -The `:Pendulum` command generates and shows the metrics view (i.e. time spent -per branch, project, filetype, etc.). Report generation will take longer as the -size of your log file increases. - -The `:PendulumHours` command generates and shows the active hours view, which -shows which times of day you are most active (and time spent). - -If you do not want to install Go, report generation can be disabled by changing -the `gen_reports` option to `false`. Disabling reports will cause the -`Pendulum`, `PendulumHours`, and `PendulumRebuild` commands to not be created -since they are exclusively used for the reports feature. - ->lua - config = function() - require("pendulum").setup({ - -- disable report generations (avoids Go dependency) - gen_reports = false, - }) - end, -< - -The metrics report contents are customizable and section items or entire -sections can be excluded from the report if desired. (See `report_excludes` and -`report_section_excludes` options in setup) - - -FUTURE IDEAS *pendulum-nvim-pendulum-nvim-future-ideas* - -These are some potential future ideas that would make for welcome contributions -for anyone interested. - -- Logging to SQLite database (optionally) -- Telescope integration -- Get stats for specified project, filetype, etc. (Could work well with Telescope) -- Nicer looking popup with custom highlight groups -- Alternative version of popup that uses a terminal buffer and bubbletea (using the table component) - -============================================================================== -2. Links *pendulum-nvim-links* - -1. *Pendulum Metrics View*: ./assets/screenshot0.png -2. *Pendulum Active Hours View*: ./assets/screenshot1.png - -Generated by panvimdoc - -vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3f4f031 --- /dev/null +++ b/flake.lock @@ -0,0 +1,26 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1753432016, + "narHash": "sha256-cnL5WWn/xkZoyH/03NNUS7QgW5vI7D1i74g48qplCvg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6027c30c8e9810896b92429f0092f624f7b1aace", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixpkgs-unstable", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..aead6e8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,24 @@ +{ + description = "Rust dev shell flake"; + inputs = { + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + }; + outputs = { nixpkgs, ... }: let + forAllSystems = function: + nixpkgs.lib.genAttrs [ + "x86_64-linux" + "aarch64-linux" + ] (system: + function nixpkgs.legacyPackages.${system} + ); + in { + devShells = forAllSystems(pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + openssl + pkg-config + ]; + }; + }); + }; +} diff --git a/lua/pendulum/csv.lua b/lua/pendulum/csv.lua deleted file mode 100644 index 0e0602f..0000000 --- a/lua/pendulum/csv.lua +++ /dev/null @@ -1,112 +0,0 @@ -local M = {} - ----@param field any ----@return string -local function escape_csv_field(field) - if type(field) == "string" and (field:find('[,"]') or field:find("\n")) then - field = '"' .. field:gsub('"', '""') .. '"' - end - return tostring(field) -end - ----convert lua table to csv style table ----@param t table ----@return string, table -local function table_to_csv(t) - if #t == 0 then - return "", {} - end - - local csv_data = {} - local headers = {} - - for key, _ in pairs(t[1]) do - table.insert(headers, key) - end - table.sort(headers) - - for _, row in ipairs(t) do - local temp = {} - for _, field_key in ipairs(headers) do - table.insert(temp, escape_csv_field(row[field_key])) - end - - -- Incomplete rows can sometimes occur when multiple Neovim sessions are open, - -- so validating the number of entries matches the number of headers prevents - -- incomplete insertions. - if #temp == #headers then - table.insert(csv_data, table.concat(temp, ",") .. "\n") - end - end - - return table.concat(csv_data), headers -end - ----write lua table to csv file ----@param filepath string ----@param data_table table ----@param include_header boolean -function M.write_table_to_csv(filepath, data_table, include_header) - filepath = filepath:gsub("\\", "\\\\") - local d = filepath:match("^(.*[\\/])") - if vim.fn.isdirectory(d) == 0 then - vim.fn.mkdir(d, "p") - end - local f = io.open(filepath, "a+") - if not f then - error("Error opening file: " .. filepath) - end - - local csv_content, headers = table_to_csv(data_table) - if f:seek("end") == 0 and include_header then - f:write(table.concat(headers, ",") .. "\n") - end - - if csv_content ~= "" then - f:write(csv_content) - else - print("No data to write.") - end - - f:close() -end - ----function to split a string by a given delimiter ----@param input string ----@param delimiter string ----@return table -local function split(input, delimiter) - local result = {} - for match in (input .. delimiter):gmatch("(.-)" .. delimiter) do - table.insert(result, match) - end - return result -end - ----read a csv file into a lua table ----@param filepath string ----@return table? -function M.read_csv(filepath) - local csv_file = io.open(filepath, "r") - if not csv_file then - print("Could not open file: " .. filepath) - return nil - end - - local headers = split(csv_file:read("*l"), ",") - local data = {} - - for line in csv_file:lines() do - local row = {} - local values = split(line, ",") - for i, header in ipairs(headers) do - row[header] = values[i] - end - table.insert(data, row) - end - - csv_file:close() - return data -end - -return M diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 90abc92..a5c3f37 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -1,127 +1,171 @@ local M = {} -local csv = require("pendulum.csv") - ----initialize last active time local last_active_time = os.time() - local flag = true +local lsp_client = nil + +local csv_path_set = false +local pending_queue = {} ----update last active time local function update_activity() last_active_time = os.time() end ----get the name of the git project ----@return string -local function git_project() - -- TODO: possibly change cwd to file path (to capture its git project while in a different working directory) - local project_name = vim.system( - { "git", "config", "--local", "remote.origin.url" }, - { text = true, cwd = vim.loop.cwd() } - ) - :wait().stdout - - if project_name then - project_name = project_name:gsub("%s+$", ""):match(".*/([^.]+)%.git$") +local function flush_pending() + if not lsp_client then + return end - return project_name or "unknown_project" + if #pending_queue > 0 then + for _, activity in ipairs(pending_queue) do + lsp_client.request("workspace/executeCommand", { + command = "pendulum.logActivity", + arguments = { activity }, + }, function(err, _) + if err then + vim.notify( + "Failed to log queued activity: " + .. tostring(err.message or err), + vim.log.levels.ERROR + ) + end + end, 0) + end + pending_queue = {} + end end ----get name of current git branch ----@return string -local function git_branch() - -- TODO: possibly change cwd to file path (to capture its git project while in a different working directory) - local branch_name = vim.system( - { "git", "branch", "--show-current" }, - { text = true, cwd = vim.loop.cwd() } - ) - :wait().stdout - - if not branch_name or branch_name == "" or branch_name:match("^fatal:") then - return "unknown_branch" +local function send_to_lsp(activity_data) + if not lsp_client or lsp_client.is_stopped() then + -- vim.notify("Pendulum LSP client not initialized", vim.log.levels.WARN) + return + end + + if not csv_path_set then + table.insert(pending_queue, activity_data) + return end - return branch_name:gsub("%s+$", "") or "unknown_branch" + lsp_client.request("workspace/executeCommand", { + command = "pendulum.logActivity", + arguments = { activity_data }, + }, function(err, _) + if err then + vim.notify( + "Failed to log activity: " .. tostring(err.message or err), + vim.log.levels.ERROR + ) + end + end, 0) end ----get table of tracked metrics ----@param is_active boolean ----@param active_time integer? ----@return table -local function log_activity(is_active, opts, active_time) - local _ = active_time - local ft = vim.bo.filetype - if ft == "" then - ft = "unknown_filetype" +local function init_lsp_client(opts) + if lsp_client then + return lsp_client + end + + local client_id = vim.lsp.start({ + name = "pendulum-lsp", + cmd = { opts.lsp_binary }, + root_dir = vim.fn.getcwd(), + filetypes = {}, + on_attach = function(client, bufnr) + client:request("workspace/executeCommand", { + command = "pendulum.setCsvPath", + arguments = { opts.log_file }, + }, function(err, _) + if err then + vim.notify( + "Failed to set CSV path: " + .. tostring(err.message or err), + vim.log.levels.ERROR + ) + else + csv_path_set = true + flush_pending() + end + end, bufnr) + end, + on_init = function(_) + flush_pending() + end, + on_exit = function(code, _, _) + lsp_client = nil + vim.notify( + "Pendulum LSP server exited with code: " .. tostring(code), + vim.log.levels.WARN + ) + end, + }) + + if client_id then + lsp_client = vim.lsp.get_client_by_id(client_id) + else + vim.notify( + "Failed to start Pendulum LSP server: " .. opts.lsp_binary, + vim.log.levels.ERROR + ) end + + return lsp_client +end + +local function log_activity(is_active, active_time) + local time = active_time or os.time() + local ft = vim.bo.filetype ~= "" and vim.bo.filetype or "unknown_filetype" + local data = { - -- time = vim.fn.strftime("%Y-%m-%d %H:%M:%S"), -- Use local time zone instead - time = os.date("!%Y-%m-%d %H:%M:%S"), + time = os.date("!%Y-%m-%d %H:%M:%S", time), active = tostring(is_active), - -- file = vim.fn.expand("%:t+"), -- only file name - file = vim.fn.expand("%:p"), -- file name with path - -- TODO: file path - filename -> handoff to git to get file names - -- - change cwd to file path without filename + file = vim.fn.expand("%:p"), filetype = ft, cwd = vim.loop.cwd(), - project = git_project(), - branch = git_branch(), } + if data.file ~= "" then - csv.write_table_to_csv(opts.log_file, { data }, true) + send_to_lsp(data) end return data end ----Check if the user is currently active ----@param opts table local function check_active_status(opts) local is_active = os.time() - last_active_time < opts.timeout_len - - -- for first non-active entry, log last active time if not is_active and flag then flag = false - log_activity(true, opts, last_active_time) + log_activity(true, last_active_time) elseif is_active and not flag then flag = true end - - log_activity(is_active, opts) + log_activity(is_active) end ----Setup periodic activity checks ----@param opts table function M.setup(opts) + opts.lsp_binary = opts.lsp_binary or "pendulum-lsp" update_activity() - -- create autocommand group vim.api.nvim_create_augroup("Pendulum", { clear = true }) - -- define autocmd to update last active time vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { group = "Pendulum", - callback = function() - update_activity() - end, + callback = update_activity, }) - -- define autocmd for logging events vim.api.nvim_create_autocmd({ "BufEnter", "VimLeave" }, { group = "Pendulum", callback = function() - log_activity(true, opts) + log_activity(true) end, }) - -- logging timer - vim.fn.timer_start(opts.timer_len * 1000, function() - vim.schedule(function() - check_active_status(opts) - end) - end, { ["repeat"] = -1 }) + vim.defer_fn(function() + init_lsp_client(opts) + vim.fn.timer_start(opts.timer_len * 1000, function() + vim.schedule(function() + check_active_status(opts) + end) + end, { ["repeat"] = -1 }) + end, 100) end return M diff --git a/lua/pendulum/init.lua b/lua/pendulum/init.lua index fffce35..85d5a37 100644 --- a/lua/pendulum/init.lua +++ b/lua/pendulum/init.lua @@ -21,6 +21,7 @@ local default_opts = { project = {}, }, report_section_excludes = {}, + lsp_binary = nil, } ---set up plugin autocommands with user options diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a4db077 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,285 @@ +use std::collections::HashMap; +use std::fs::{File, OpenOptions, create_dir_all}; +use std::path::Path; +use std::process::Command; + +use anyhow::{Result, anyhow}; +use csv::{ReaderBuilder, WriterBuilder}; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::Mutex; +use tower_lsp::jsonrpc::{Error, Result as LspResult}; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer, LspService, Server}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ActivityData { + active: String, + branch: String, + cwd: String, + file: String, + filetype: String, + project: String, + time: String, +} + +struct PendulumLsp { + client: Client, + csv_file_path: Mutex>, +} + +impl PendulumLsp { + fn new(client: Client) -> Self { + Self { + client, + csv_file_path: Mutex::new(None), + } + } + + fn get_git_project(cwd: &str) -> String { + match Command::new("git") + .args(["config", "--local", "remote.origin.url"]) + .current_dir(cwd) + .output() + { + Ok(output) => { + let url = String::from_utf8_lossy(&output.stdout); + let trimmed = url.trim(); + if let Some(captures) = regex::Regex::new(r".*/([^.]+)\.git$") + .unwrap() + .captures(trimmed) + { + captures[1].to_string() + } else { + "unknown_project".to_string() + } + } + Err(_) => "unknown_project".to_string(), + } + } + + fn get_git_branch(cwd: &str) -> String { + match Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(cwd) + .output() + { + Ok(output) => { + let branch = String::from_utf8_lossy(&output.stdout); + let trimmed = branch.trim(); + if trimmed.is_empty() || trimmed.starts_with("fatal:") { + "unknown_branch".to_string() + } else { + trimmed.to_string() + } + } + Err(_) => "unknown_branch".to_string(), + } + } + + async fn write_csv_data(&self, data: &ActivityData) -> Result<()> { + let csv_path = self.csv_file_path.lock().await; + let path = csv_path + .as_ref() + .ok_or_else(|| anyhow!("CSV file path not set"))?; + + // Create directory if it doesn't exist + if let Some(parent) = Path::new(path).parent() { + create_dir_all(parent)?; + } + + let file_exists = Path::new(path).exists(); + let mut file_empty = false; + + if file_exists { + let metadata = std::fs::metadata(path)?; + file_empty = metadata.len() == 0; + } + + let file = OpenOptions::new().create(true).append(true).open(path)?; + + let mut writer = WriterBuilder::new().from_writer(file); + + // Write header if file is new or empty + if !file_exists || file_empty { + writer.write_record([ + "active", "branch", "cwd", "file", "filetype", "project", "time", + ])?; + } + + // Write data row + writer.write_record([ + &data.active, + &data.branch, + &data.cwd, + &data.file, + &data.filetype, + &data.project, + &data.time, + ])?; + + writer.flush()?; + Ok(()) + } + + // async fn read_csv_data(&self) -> Result> { + // let csv_path = self.csv_file_path.lock().await; + // let path = csv_path + // .as_ref() + // .ok_or_else(|| anyhow!("CSV file path not set"))?; + // + // if !Path::new(path).exists() { + // return Ok(Vec::new()); + // } + // + // let file = File::open(path)?; + // let mut reader = ReaderBuilder::new().from_reader(file); + // let mut data = Vec::new(); + // + // for result in reader.deserialize() { + // match result { + // Ok(record) => { + // let activity: ActivityData = record; + // data.push(activity); + // } + // Err(e) => { + // warn!("Error reading CSV record: {}", e); + // } + // } + // } + // + // Ok(data) + // } +} + +#[tower_lsp::async_trait] +impl LanguageServer for PendulumLsp { + async fn initialize(&self, _params: InitializeParams) -> LspResult { + info!("Pendulum LSP initializing..."); + + Ok(InitializeResult { + capabilities: ServerCapabilities { + execute_command_provider: Some(ExecuteCommandOptions { + commands: vec![ + "pendulum.logActivity".to_string(), + "pendulum.setCsvPath".to_string(), + "pendulum.readCsv".to_string(), + ], + ..Default::default() + }), + ..Default::default() + }, + server_info: Some(ServerInfo { + name: "Pendulum LSP".to_string(), + version: Some("0.1.0".to_string()), + }), + }) + } + + async fn initialized(&self, _: InitializedParams) { + info!("Pendulum LSP initialized"); + self.client + .log_message(MessageType::INFO, "Pendulum LSP server initialized") + .await; + } + + async fn shutdown(&self) -> LspResult<()> { + info!("Pendulum LSP shutting down"); + Ok(()) + } + + async fn execute_command(&self, params: ExecuteCommandParams) -> LspResult> { + match params.command.as_str() { + "pendulum.setCsvPath" => { + if let Some(Value::String(path)) = params.arguments.first() { + let mut csv_path = self.csv_file_path.lock().await; + *csv_path = Some(path.clone()); + info!("CSV path set to: {path}"); + self.client + .log_message(MessageType::INFO, format!("CSV path set to: {path}")) + .await; + Ok(Some(Value::Bool(true))) + } else { + Err(Error::invalid_params("Missing or invalid CSV path")) + } + } + "pendulum.logActivity" => { + if let Some(args) = params.arguments.first() { + match serde_json::from_value::>(args.clone()) { + Ok(data) => { + let activity = ActivityData { + time: data + .get("time") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + active: data + .get("active") + .and_then(|v| v.as_str()) + .unwrap_or("false") + .to_string(), + file: data + .get("file") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + filetype: data + .get("filetype") + .and_then(|v| v.as_str()) + .unwrap_or("unknown_filetype") + .to_string(), + cwd: data + .get("cwd") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + project: Self::get_git_project( + data.get("cwd").and_then(|v| v.as_str()).unwrap_or(""), + ), + branch: Self::get_git_branch( + data.get("cwd").and_then(|v| v.as_str()).unwrap_or(""), + ), + }; + + match self.write_csv_data(&activity).await { + Ok(_) => { + info!("Activity logged successfully"); + Ok(Some(Value::Bool(true))) + } + Err(e) => { + error!("Failed to write CSV data: {e}"); + self.client + .log_message( + MessageType::ERROR, + format!("Failed to write CSV data: {e}"), + ) + .await; + Err(Error::internal_error()) + } + } + } + Err(e) => { + error!("Failed to parse activity data: {e}"); + Err(Error::invalid_params("Invalid activity data format")) + } + } + } else { + Err(Error::invalid_params("Missing activity data")) + } + } + _ => Err(Error::method_not_found()), + } + } +} + +#[tokio::main] +async fn main() { + env_logger::init(); + + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = LspService::new(PendulumLsp::new); + Server::new(stdin, stdout, socket).serve(service).await; +} From bf964304ee9ee6b28c6b385ea09567cecb229f06 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Sun, 27 Jul 2025 15:52:05 -0400 Subject: [PATCH 02/20] refactor: remove unecessary queueing structure from handlers.lua --- Cargo.lock | 53 ++++++++++++++++ Cargo.toml | 1 + lua/pendulum/handlers.lua | 130 ++++++++++++++++++++------------------ src/main.rs | 80 +++++++---------------- 4 files changed, 145 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30b5751..4124d38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,46 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + [[package]] name = "colorchoice" version = "1.0.4" @@ -321,6 +361,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "httparse" version = "1.10.1" @@ -597,6 +643,7 @@ name = "pendulum-lsp" version = "0.1.0" dependencies = [ "anyhow", + "clap", "csv", "env_logger", "log", @@ -823,6 +870,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.104" diff --git a/Cargo.toml b/Cargo.toml index d502638..521f595 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1.0.98" csv = "1.3.1" env_logger = "0.11.8" log = "0.4.27" +clap = { version = "4.0", features = ["derive"] } regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.141" diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index a5c3f37..782bab6 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -1,48 +1,15 @@ local M = {} local last_active_time = os.time() -local flag = true +local active_flag = true local lsp_client = nil -local csv_path_set = false -local pending_queue = {} - local function update_activity() last_active_time = os.time() end -local function flush_pending() - if not lsp_client then - return - end - - if #pending_queue > 0 then - for _, activity in ipairs(pending_queue) do - lsp_client.request("workspace/executeCommand", { - command = "pendulum.logActivity", - arguments = { activity }, - }, function(err, _) - if err then - vim.notify( - "Failed to log queued activity: " - .. tostring(err.message or err), - vim.log.levels.ERROR - ) - end - end, 0) - end - pending_queue = {} - end -end - local function send_to_lsp(activity_data) if not lsp_client or lsp_client.is_stopped() then - -- vim.notify("Pendulum LSP client not initialized", vim.log.levels.WARN) - return - end - - if not csv_path_set then - table.insert(pending_queue, activity_data) return end @@ -64,35 +31,43 @@ local function init_lsp_client(opts) return lsp_client end + -- Check if the binary exists and is executable + -- TODO: remove this option and replace with plugin path + local binary_path = opts.lsp_binary + local stat = vim.loop.fs_stat(binary_path) + + if not stat then + vim.notify( + "Pendulum LSP binary not found: " .. binary_path, + vim.log.levels.ERROR + ) + return nil + end + + if not vim.fn.executable(binary_path) then + vim.notify( + "Pendulum LSP binary is not executable: " .. binary_path, + vim.log.levels.ERROR + ) + return nil + end + local client_id = vim.lsp.start({ name = "pendulum-lsp", - cmd = { opts.lsp_binary }, - root_dir = vim.fn.getcwd(), + cmd = { binary_path, "--csv-path", opts.log_file }, + root_dir = vim.loop.cwd(), filetypes = {}, on_attach = function(client, bufnr) - client:request("workspace/executeCommand", { - command = "pendulum.setCsvPath", - arguments = { opts.log_file }, - }, function(err, _) - if err then - vim.notify( - "Failed to set CSV path: " - .. tostring(err.message or err), - vim.log.levels.ERROR - ) - else - csv_path_set = true - flush_pending() - end - end, bufnr) - end, - on_init = function(_) - flush_pending() + vim.lsp.log.debug("Pendulum LSP attached") end, - on_exit = function(code, _, _) + on_exit = function(code, signal, _) lsp_client = nil vim.notify( - "Pendulum LSP server exited with code: " .. tostring(code), + string.format( + "Pendulum LSP server exited with code: %s, signal: %s", + tostring(code), + tostring(signal) + ), vim.log.levels.WARN ) end, @@ -100,9 +75,13 @@ local function init_lsp_client(opts) if client_id then lsp_client = vim.lsp.get_client_by_id(client_id) + vim.lsp.log.debug( + "Pendulum LSP client started with ID: " .. client_id, + vim.log.levels.INFO + ) else vim.notify( - "Failed to start Pendulum LSP server: " .. opts.lsp_binary, + "Failed to start Pendulum LSP server: " .. binary_path, vim.log.levels.ERROR ) end @@ -131,17 +110,29 @@ end local function check_active_status(opts) local is_active = os.time() - last_active_time < opts.timeout_len - if not is_active and flag then - flag = false + if not is_active and active_flag then + active_flag = false log_activity(true, last_active_time) - elseif is_active and not flag then - flag = true + elseif is_active and not active_flag then + active_flag = true end log_activity(is_active) end function M.setup(opts) + opts = opts or {} opts.lsp_binary = opts.lsp_binary or "pendulum-lsp" + opts.timeout_len = opts.timeout_len or 5 + opts.timer_len = opts.timer_len or 1 + + if not opts.log_file then + vim.notify( + "Pendulum: log_file is required in setup options", + vim.log.levels.ERROR + ) + return + end + update_activity() vim.api.nvim_create_augroup("Pendulum", { clear = true }) @@ -151,15 +142,30 @@ function M.setup(opts) callback = update_activity, }) - vim.api.nvim_create_autocmd({ "BufEnter", "VimLeave" }, { + vim.api.nvim_create_autocmd({ "BufEnter" }, { group = "Pendulum", callback = function() log_activity(true) end, }) + vim.api.nvim_create_autocmd({ "VimLeave" }, { + group = "Pendulum", + callback = function() + if lsp_client and not lsp_client.is_stopped() then + log_activity(true) + end + end, + }) + + -- Initialize LSP client immediately (deferred to next tick) + -- FIX: why does this error out if not deferred? vim.defer_fn(function() init_lsp_client(opts) + end, 0) + + -- Start the activity checking timer + vim.defer_fn(function() vim.fn.timer_start(opts.timer_len * 1000, function() vim.schedule(function() check_active_status(opts) diff --git a/src/main.rs b/src/main.rs index a4db077..25e1a43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,15 +4,24 @@ use std::path::Path; use std::process::Command; use anyhow::{Result, anyhow}; +use clap::Parser; use csv::{ReaderBuilder, WriterBuilder}; use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::Mutex; use tower_lsp::jsonrpc::{Error, Result as LspResult}; use tower_lsp::lsp_types::*; use tower_lsp::{Client, LanguageServer, LspService, Server}; +#[derive(Parser, Debug)] +#[command(name = "pendulum-lsp")] +#[command(about = "Pendulum time tracking LSP server")] +struct Args { + /// Path to the CSV log file + #[arg(long, short)] + csv_path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct ActivityData { active: String, @@ -26,14 +35,14 @@ struct ActivityData { struct PendulumLsp { client: Client, - csv_file_path: Mutex>, + csv_file_path: String, } impl PendulumLsp { - fn new(client: Client) -> Self { + fn new(client: Client, csv_path: String) -> Self { Self { client, - csv_file_path: Mutex::new(None), + csv_file_path: csv_path, } } @@ -79,10 +88,7 @@ impl PendulumLsp { } async fn write_csv_data(&self, data: &ActivityData) -> Result<()> { - let csv_path = self.csv_file_path.lock().await; - let path = csv_path - .as_ref() - .ok_or_else(|| anyhow!("CSV file path not set"))?; + let path = &self.csv_file_path; // Create directory if it doesn't exist if let Some(parent) = Path::new(path).parent() { @@ -122,50 +128,20 @@ impl PendulumLsp { writer.flush()?; Ok(()) } - - // async fn read_csv_data(&self) -> Result> { - // let csv_path = self.csv_file_path.lock().await; - // let path = csv_path - // .as_ref() - // .ok_or_else(|| anyhow!("CSV file path not set"))?; - // - // if !Path::new(path).exists() { - // return Ok(Vec::new()); - // } - // - // let file = File::open(path)?; - // let mut reader = ReaderBuilder::new().from_reader(file); - // let mut data = Vec::new(); - // - // for result in reader.deserialize() { - // match result { - // Ok(record) => { - // let activity: ActivityData = record; - // data.push(activity); - // } - // Err(e) => { - // warn!("Error reading CSV record: {}", e); - // } - // } - // } - // - // Ok(data) - // } } #[tower_lsp::async_trait] impl LanguageServer for PendulumLsp { async fn initialize(&self, _params: InitializeParams) -> LspResult { - info!("Pendulum LSP initializing..."); + info!( + "Pendulum LSP initializing with CSV path: {}", + self.csv_file_path + ); Ok(InitializeResult { capabilities: ServerCapabilities { execute_command_provider: Some(ExecuteCommandOptions { - commands: vec![ - "pendulum.logActivity".to_string(), - "pendulum.setCsvPath".to_string(), - "pendulum.readCsv".to_string(), - ], + commands: vec!["pendulum.logActivity".to_string()], ..Default::default() }), ..Default::default() @@ -191,19 +167,6 @@ impl LanguageServer for PendulumLsp { async fn execute_command(&self, params: ExecuteCommandParams) -> LspResult> { match params.command.as_str() { - "pendulum.setCsvPath" => { - if let Some(Value::String(path)) = params.arguments.first() { - let mut csv_path = self.csv_file_path.lock().await; - *csv_path = Some(path.clone()); - info!("CSV path set to: {path}"); - self.client - .log_message(MessageType::INFO, format!("CSV path set to: {path}")) - .await; - Ok(Some(Value::Bool(true))) - } else { - Err(Error::invalid_params("Missing or invalid CSV path")) - } - } "pendulum.logActivity" => { if let Some(args) = params.arguments.first() { match serde_json::from_value::>(args.clone()) { @@ -277,9 +240,12 @@ impl LanguageServer for PendulumLsp { async fn main() { env_logger::init(); + let args = Args::parse(); + let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); - let (service, socket) = LspService::new(PendulumLsp::new); + let (service, socket) = + LspService::build(|client| PendulumLsp::new(client, args.csv_path)).finish(); Server::new(stdin, stdout, socket).serve(service).await; } From c63a393454ea1727ac65e83f6fb138a66dcdc0a9 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Sun, 27 Jul 2025 22:26:05 -0400 Subject: [PATCH 03/20] wip go lsp implementation --- .gitignore | 1 + go.mod | 26 ++++ go.sum | 40 ++++++ internal/handlers/logger.go | 61 +++++++++ internal/handlers/metrics.go | 24 ++++ internal/lsp/lsp.go | 72 +++++++++++ lua/pendulum/handlers.lua | 9 +- lua/pendulum/remote.lua | 239 +++++++++++++++++++---------------- main.go | 32 +++++ 9 files changed, 395 insertions(+), 109 deletions(-) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/handlers/logger.go create mode 100644 internal/handlers/metrics.go create mode 100644 internal/lsp/lsp.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore index 2a563d4..7f6b4f0 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ luac.out ## Extras remote/pendulum-nvim +pendulum-server .luarc.json diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..086a514 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/ptdewey/pendulum-server + +go 1.24.4 + +require github.com/tliron/glsp v0.2.2 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sasha-s/go-deadlock v0.3.1 // indirect + github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect + github.com/tliron/commonlog v0.2.8 // indirect + github.com/tliron/kutil v0.3.11 // indirect + golang.org/x/crypto v0.15.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/term v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..32646c4 --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= +github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= +github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= +github.com/sourcegraph/jsonrpc2 v0.2.0 h1:KjN/dC4fP6aN9030MZCJs9WQbTOjWHhrtKVpzzSrr/U= +github.com/sourcegraph/jsonrpc2 v0.2.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= +github.com/tliron/commonlog v0.2.8 h1:vpKrEsZX4nlneC9673pXpeKqv3cFLxwpzNEZF1qiaQQ= +github.com/tliron/commonlog v0.2.8/go.mod h1:HgQZrJEuiKLLRvUixtPWGcmTmWWtKkCtywF6x9X5Spw= +github.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c= +github.com/tliron/glsp v0.2.2/go.mod h1:GMVWDNeODxHzmDPvYbYTCs7yHVaEATfYtXiYJ9w1nBg= +github.com/tliron/kutil v0.3.11 h1:kongR0dhrrn9FR/3QRFoUfQe27t78/xQvrU9aXIy5bk= +github.com/tliron/kutil v0.3.11/go.mod h1:4IqOAAdpJuDxYbJxMv4nL8LSH0mPofSrdwIv8u99PDc= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= diff --git a/internal/handlers/logger.go b/internal/handlers/logger.go new file mode 100644 index 0000000..c255993 --- /dev/null +++ b/internal/handlers/logger.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "fmt" + "log" + + "github.com/tliron/glsp" +) + +// TODO: + +type activityData struct { + active bool + branch string + cwd string + file string + filetype string + project string + time string +} + +// TODO: output should just be error +func LogActivity(ctx *glsp.Context, args []any) (bool, error) { + // TODO: log with ctx? (allow different log levels) + log.Printf("executing command 'pendulum.logActivity' with args %v (%T)", args, args) + + m, ok := args[0].(map[string]any) + if !ok { + // TODO: log error? + return false, fmt.Errorf("invalid args") + } + + ad := activityData{} + + if active, exists := m["active"].(bool); exists { + ad.active = active + } + if branch, exists := m["branch"].(string); exists { + ad.branch = branch + } + if cwd, err := m["cwd"].(string); err { + ad.cwd = cwd + } + if file, exists := m["file"].(string); exists { + ad.file = file + } + if filetype, exists := m["filetype"].(string); exists { + ad.filetype = filetype + } + if project, exists := m["project"].(string); exists { + ad.project = project + } + if time, exists := m["time"].(string); exists { + ad.time = time + } + + // TODO: write to csv + // - create if not exist + + return false, nil +} diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go new file mode 100644 index 0000000..5d909e9 --- /dev/null +++ b/internal/handlers/metrics.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "log" + + "github.com/tliron/glsp" +) + +// TODO: rename to "summary" report +func GenerateMetricsReport(ctx *glsp.Context, args []any) (string, error) { + log.Printf("executing command 'pendulum.generateMetricsReport' with args %v (%T)", args, args) + // TODO: + return "", nil +} + +// TODO: find a better name for this one +func GenerateHourlyReport(ctx *glsp.Context, args []any) (string, error) { + log.Printf("executing command 'pendulum.generateHourlyReport' with args %v (%T)", args, args) + + // ctx.Notify() + + // TODO: + return "", nil +} diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go new file mode 100644 index 0000000..34bbd2e --- /dev/null +++ b/internal/lsp/lsp.go @@ -0,0 +1,72 @@ +package lsp + +import ( + "fmt" + "log" + + "github.com/ptdewey/pendulum-server/internal/handlers" + "github.com/tliron/glsp" + protocol "github.com/tliron/glsp/protocol_3_16" +) + +const ( + cmdLogActivity string = "pendulum.logActivity" + cmdGenerateMetricsReport string = "pendulum.generateMetricsReport" + cmdGenerateHourlyReport string = "pendulum.generateHourlyReport" +) + +func Initialize(ctx *glsp.Context, params *protocol.InitializeParams) (any, error) { + capabilities := protocol.ServerCapabilities{ + ExecuteCommandProvider: &protocol.ExecuteCommandOptions{ + Commands: []string{ + cmdLogActivity, cmdGenerateMetricsReport, cmdGenerateHourlyReport, + }, + }, + } + + return protocol.InitializeResult{ + Capabilities: capabilities, + ServerInfo: &protocol.InitializeResultServerInfo{ + Name: "pendulum-server", + Version: &[]string{"2.0.0"}[0], + }, + }, nil +} + +func Initialized(ctx *glsp.Context, params *protocol.InitializedParams) error { + log.Println("Server initialized successfully") + + ctx.Notify(protocol.ServerWindowLogMessage, &protocol.ShowMessageParams{ + Type: protocol.MessageTypeInfo, + Message: "pendulum-server is ready", + }) + + return nil +} + +func Shutdown(ctx *glsp.Context) error { + log.Println("pendulum-server shutting down") + return nil +} + +func WorkspaceExecuteCommand(ctx *glsp.Context, params *protocol.ExecuteCommandParams) (any, error) { + switch params.Command { + case cmdLogActivity: + return handlers.LogActivity(ctx, params.Arguments) + case cmdGenerateMetricsReport: + return handlers.GenerateMetricsReport(ctx, params.Arguments) + case cmdGenerateHourlyReport: + default: + return nil, fmt.Errorf("unknown command: %s", params.Command) + } + + return nil, fmt.Errorf("not yet implemented") +} + +// These may of potential use (removing the need for some autocommands) +// TextDocumentDidOpen TextDocumentDidOpenFunc +// TextDocumentDidChange TextDocumentDidChangeFunc +// TextDocumentWillSave TextDocumentWillSaveFunc +// TextDocumentWillSaveWaitUntil TextDocumentWillSaveWaitUntilFunc +// TextDocumentDidSave TextDocumentDidSaveFunc +// TextDocumentDidClose TextDocumentDidCloseFunc diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 782bab6..5d0eee6 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -32,7 +32,6 @@ local function init_lsp_client(opts) end -- Check if the binary exists and is executable - -- TODO: remove this option and replace with plugin path local binary_path = opts.lsp_binary local stat = vim.loop.fs_stat(binary_path) @@ -119,6 +118,11 @@ local function check_active_status(opts) log_activity(is_active) end +-- Expose the LSP client to other modules +function M.get_lsp_client() + return lsp_client +end + function M.setup(opts) opts = opts or {} opts.lsp_binary = opts.lsp_binary or "pendulum-lsp" @@ -159,7 +163,6 @@ function M.setup(opts) }) -- Initialize LSP client immediately (deferred to next tick) - -- FIX: why does this error out if not deferred? vim.defer_fn(function() init_lsp_client(opts) end, 0) @@ -174,4 +177,4 @@ function M.setup(opts) end, 100) end -return M +return M \ No newline at end of file diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index caf02b1..9619b1c 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -1,146 +1,173 @@ local M = {} -local chan -local bin_path -local plugin_path - local options = {} ---- job runner for pendulum remote binary ----@return integer? -local function ensure_job() - if chan then - return chan - end +-- Function to create a buffer with the content received from the LSP +local function create_buffer(content, filetype) + -- Create a new scratch buffer + local buf = vim.api.nvim_create_buf(false, true) - if not bin_path then - print("Error: Pendulum binary not found.") - return - end + -- Set buffer options + vim.api.nvim_buf_set_option(buf, "filetype", filetype or "markdown") - chan = vim.fn.jobstart({ bin_path }, { - rpc = true, - on_exit = function(_, code, _) - if code ~= 0 then - print("Error: Pendulum job exited with code " .. code) - chan = nil - end - end, - on_stderr = function(_, data, _) - for _, line in ipairs(data) do - if line ~= "" then - print("stderr: " .. line) + -- Process content based on type + local lines = {} + + if type(content) == "table" then + -- If content is an array + for _, section in ipairs(content) do + if type(section) == "string" then + -- If it's a string, split it on newlines and add each line + for line in section:gmatch("[^\r\n]+") do + table.insert(lines, line) end - end - end, - on_stdout = function(_, data, _) - for _, line in ipairs(data) do - if line ~= "" then - print("stdout: " .. line) + elseif type(section) == "table" then + -- If it's an array of strings + for _, line in ipairs(section) do + if type(line) == "string" then + table.insert(lines, line) + end end end - end, - }) + end + elseif type(content) == "string" then + -- For a single string + for line in content:gmatch("[^\r\n]+") do + table.insert(lines, line) + end + end + + -- Set buffer content line by line + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + + -- Set buffer keymap for close + vim.api.nvim_buf_set_keymap( + buf, + "n", + "q", + "close!", + { silent = true } + ) + + return buf +end + +-- Function to create a popup window with a buffer +local function create_popup_window(buf) + -- Get screen dimensions + local screen_width = vim.api.nvim_get_option("columns") + local screen_height = vim.api.nvim_get_option("lines") + + -- Calculate popup dimensions + local popup_width = math.floor(screen_width * 0.85) + local popup_height = math.floor(screen_height * 0.85) + + -- Create window config + local win_config = { + relative = "editor", + row = math.floor((screen_height - popup_height) / 2) - 1, + col = math.floor((screen_width - popup_width) / 2), + width = popup_width, + height = popup_height, + style = "minimal", + border = "rounded", + zindex = 50, + } + + -- Open window with buffer + local win = vim.api.nvim_open_win(buf, true, win_config) + + return win +end - if not chan or chan == 0 then - error("Failed to start pendulum-nvim job") +-- Function to handle LSP report generation responses +local function handle_lsp_response(err, result, context) + if err then + vim.notify( + "Error generating report: " .. vim.inspect(err), + vim.log.levels.ERROR + ) + return end - return chan + if not result then + vim.notify("No data returned from LSP", vim.log.levels.WARN) + return + end + + -- Create buffer with the content + local buf = create_buffer(result, "markdown") + + -- Create popup window + create_popup_window(buf) end ---- create plugin user commands to build binary and show report -local function setup_pendulum_commands() +-- Setup pendulum commands for report generation +local function setup_pendulum_commands(lsp_client) vim.api.nvim_create_user_command("Pendulum", function(args) - chan = ensure_job() - if not chan or chan == 0 then - print("Error: Invalid channel") + if not lsp_client or lsp_client.is_stopped() then + vim.notify( + "Pendulum LSP client not available", + vim.log.levels.ERROR + ) return end options.time_range = args.args or "all" options.view = "metrics" - local success, result = - pcall(vim.fn.rpcrequest, chan, "pendulum", options) - if not success then - print("RPC request failed: " .. result) - end + -- Send request to LSP for metrics report + lsp_client.request("workspace/executeCommand", { + command = "pendulum.generateMetricsReport", + arguments = { options }, + }, handle_lsp_response) end, { nargs = "?" }) - vim.api.nvim_create_user_command("PendulumHours", function(args) - chan = ensure_job() - if not chan or chan == 0 then - print("Error: Invalid channel") + vim.api.nvim_create_user_command("PendulumHours", function() + if not lsp_client or lsp_client.is_stopped() then + vim.notify( + "Pendulum LSP client not available", + vim.log.levels.ERROR + ) return end options.view = "hours" - local success, result = - pcall(vim.fn.rpcrequest, chan, "pendulum", options) - if not success then - print("RPC request failed: " .. result) - end - end, { nargs = 0 }) - - vim.api.nvim_create_user_command("PendulumRebuild", function() - print("Rebuilding Pendulum binary with Go...") - local result = - os.execute("cd " .. plugin_path .. "remote" .. " && go build") - if result == 0 then - print("Go binary compiled successfully.") - if chan then - vim.fn.jobstop(chan) - chan = nil - end - else - print("Failed to compile Go binary.") - end + -- Send request to LSP for hours report + lsp_client.request("workspace/executeCommand", { + command = "pendulum.generateHoursReport", + arguments = { options }, + }, handle_lsp_response) end, { nargs = 0 }) end ---- report generation setup (requires go) ----@param opts table +-- Report generation setup using LSP function M.setup(opts) options = opts - -- get plugin install path - plugin_path = debug.getinfo(1).source:sub(2):match("(.*/).*/.*/") - - -- check os to switch separators and binary extension if necessary - local uname = vim.loop.os_uname().sysname - local path_separator = (uname == "Windows_NT") and "\\" or "/" - bin_path = plugin_path - .. "remote" - .. path_separator - .. "pendulum-nvim" - .. (uname == "Windows_NT" and ".exe" or "") - - setup_pendulum_commands() - - -- check if binary exists - local uv = vim.loop - local handle = uv.fs_open(bin_path, "r", 438) - if handle then - uv.fs_close(handle) - return - end - - -- compile binary if it doesn't exist - print( - "Pendulum binary not found at " - .. bin_path - .. ", attempting to compile with Go..." - ) + -- Get LSP client from handlers module + local handlers = require("pendulum.handlers") + local lsp_client = handlers.get_lsp_client() - local result = - os.execute("cd " .. plugin_path .. "remote" .. " && go build") - if result == 0 then - print("Go binary compiled successfully.") + -- Set up commands if client is available or wait for it + if lsp_client then + setup_pendulum_commands(lsp_client) else - print("Failed to compile Go binary." .. uv.cwd()) + -- If client not available yet, wait for it to be initialized + vim.defer_fn(function() + local client = handlers.get_lsp_client() + if client then + setup_pendulum_commands(client) + else + vim.notify( + "Unable to get Pendulum LSP client", + vim.log.levels.WARN + ) + end + end, 1000) -- Wait 1 second for LSP to initialize end end return M + diff --git a/main.go b/main.go new file mode 100644 index 0000000..e25759d --- /dev/null +++ b/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + + "github.com/ptdewey/pendulum-server/internal/lsp" + "github.com/tliron/commonlog" + protocol "github.com/tliron/glsp/protocol_3_16" + "github.com/tliron/glsp/server" +) + +var ( + name = "pendulum-server" + debug = true + logFile = "pendulum-log.csv" +) + +func main() { + commonlog.Configure(1, &logFile) + + h := protocol.Handler{ + Initialize: lsp.Initialize, + Initialized: lsp.Initialized, + WorkspaceExecuteCommand: lsp.WorkspaceExecuteCommand, + } + + s := server.NewServer(&h, "pendulum-server", debug) + + if err := s.RunStdio(); err != nil { + log.Println(err) + } +} From c5d4ff5b8b2fdb2d1b719f71adb551d2ebe078fb Mon Sep 17 00:00:00 2001 From: ptdewey Date: Mon, 28 Jul 2025 17:04:39 -0400 Subject: [PATCH 04/20] feat: more go stuff --- internal/config/config.go | 33 ++++++++++++++++++ internal/handlers/logger.go | 67 ++++++++++++++++++------------------- internal/lsp/lsp.go | 3 +- Makefile => justfile | 6 ++-- lua/pendulum/handlers.lua | 5 +-- main.go | 17 ++++++---- 6 files changed, 83 insertions(+), 48 deletions(-) create mode 100644 internal/config/config.go rename Makefile => justfile (64%) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a6a43ec --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,33 @@ +package config + +var cfg *config + +type config struct { + LogFile string + Debug bool +} + +type option func(c *config) + +func WithLogFile(path string) option { + return func(c *config) { + c.LogFile = path + } +} + +func WithDebug(debug bool) option { + return func(c *config) { + c.Debug = debug + } +} + +func Setup(opts ...option) { + cfg = new(config) + for _, o := range opts { + o(cfg) + } +} + +func Config() *config { + return cfg +} diff --git a/internal/handlers/logger.go b/internal/handlers/logger.go index c255993..c7a1c14 100644 --- a/internal/handlers/logger.go +++ b/internal/handlers/logger.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "fmt" "log" @@ -10,52 +11,48 @@ import ( // TODO: type activityData struct { - active bool - branch string - cwd string - file string - filetype string - project string - time string + Active bool `json:"active"` + Branch string `json:"branch"` + Cwd string `json:"cwd"` + File string `json:"file"` + Filetype string `json:"filetype"` + Project string `json:"project"` + Time string `json:"time"` } // TODO: output should just be error func LogActivity(ctx *glsp.Context, args []any) (bool, error) { - // TODO: log with ctx? (allow different log levels) log.Printf("executing command 'pendulum.logActivity' with args %v (%T)", args, args) - m, ok := args[0].(map[string]any) - if !ok { - // TODO: log error? - return false, fmt.Errorf("invalid args") + if len(args) == 0 { + return false, fmt.Errorf("no arguments provided") } - ad := activityData{} - - if active, exists := m["active"].(bool); exists { - ad.active = active - } - if branch, exists := m["branch"].(string); exists { - ad.branch = branch - } - if cwd, err := m["cwd"].(string); err { - ad.cwd = cwd - } - if file, exists := m["file"].(string); exists { - ad.file = file - } - if filetype, exists := m["filetype"].(string); exists { - ad.filetype = filetype - } - if project, exists := m["project"].(string); exists { - ad.project = project + // Convert to JSON and back to populate struct fields automatically + jsonBytes, err := json.Marshal(args[0]) + if err != nil { + return false, fmt.Errorf("invalid args: %w", err) } - if time, exists := m["time"].(string); exists { - ad.time = time + + var ad activityData + if err := json.Unmarshal(jsonBytes, &ad); err != nil { + return false, fmt.Errorf("failed to parse activity data: %w", err) } - // TODO: write to csv - // - create if not exist + row := ad.toCSV() + // TODO: write to csv return false, nil } + +func (ad *activityData) toCSV() string { + return fmt.Sprintf("%t,%s,%s,%s,%s,%s,%s", + ad.Active, + ad.Branch, + ad.Cwd, + ad.File, + ad.Filetype, + ad.Project, + ad.Time, + ) +} diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index 34bbd2e..28daca4 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -56,11 +56,10 @@ func WorkspaceExecuteCommand(ctx *glsp.Context, params *protocol.ExecuteCommandP case cmdGenerateMetricsReport: return handlers.GenerateMetricsReport(ctx, params.Arguments) case cmdGenerateHourlyReport: + return handlers.GenerateHourlyReport(ctx, params.Arguments) default: return nil, fmt.Errorf("unknown command: %s", params.Command) } - - return nil, fmt.Errorf("not yet implemented") } // These may of potential use (removing the need for some autocommands) diff --git a/Makefile b/justfile similarity index 64% rename from Makefile rename to justfile index 1764a0e..58fe969 100644 --- a/Makefile +++ b/justfile @@ -1,8 +1,10 @@ +[private] +default: + just --list + fmt: echo "Formatting lua/yankbank..." stylua lua/ --config-path=.stylua.toml - echo "Formatting Go files in ./remote..." - find ./remote -name '*.go' -exec gofmt -w {} + lint: echo "Linting lua/yankbank..." diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 5d0eee6..266e9d5 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -94,7 +94,7 @@ local function log_activity(is_active, active_time) local data = { time = os.date("!%Y-%m-%d %H:%M:%S", time), - active = tostring(is_active), + active = is_active, file = vim.fn.expand("%:p"), filetype = ft, cwd = vim.loop.cwd(), @@ -177,4 +177,5 @@ function M.setup(opts) end, 100) end -return M \ No newline at end of file +return M + diff --git a/main.go b/main.go index e25759d..ae827d2 100644 --- a/main.go +++ b/main.go @@ -3,20 +3,23 @@ package main import ( "log" + "github.com/ptdewey/pendulum-server/internal/config" "github.com/ptdewey/pendulum-server/internal/lsp" "github.com/tliron/commonlog" protocol "github.com/tliron/glsp/protocol_3_16" "github.com/tliron/glsp/server" ) -var ( - name = "pendulum-server" - debug = true - logFile = "pendulum-log.csv" -) +var name = "pendulum-server" func main() { - commonlog.Configure(1, &logFile) + // TODO: flags for on startup to set config vars + config.Setup( + config.WithLogFile("pendulum-log.csv"), + config.WithDebug(true), + ) + + commonlog.Configure(1, &config.Config().LogFile) h := protocol.Handler{ Initialize: lsp.Initialize, @@ -24,7 +27,7 @@ func main() { WorkspaceExecuteCommand: lsp.WorkspaceExecuteCommand, } - s := server.NewServer(&h, "pendulum-server", debug) + s := server.NewServer(&h, "pendulum-server", config.Config().Debug) if err := s.RunStdio(); err != nil { log.Println(err) From 43f0009b0b4cc37b11284f9601da9095a42b9ccc Mon Sep 17 00:00:00 2001 From: ptdewey Date: Tue, 29 Jul 2025 19:10:19 -0400 Subject: [PATCH 05/20] csv logging --- internal/config/config.go | 36 +++++++++++++++++++--- internal/handlers/logger.go | 61 ++++++++++++++++++++++++++++++++++--- justfile | 10 +++--- lua/pendulum/handlers.lua | 3 +- lua/pendulum/remote.lua | 1 - main.go | 16 ++++++---- 6 files changed, 106 insertions(+), 21 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index a6a43ec..c102542 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,31 +1,59 @@ package config +import ( + "fmt" + "os" +) + var cfg *config type config struct { - LogFile string - Debug bool + LogFile string + LspLogFile string + Debug bool } type option func(c *config) -func WithLogFile(path string) option { +func WithActivityFile(path string) option { return func(c *config) { c.LogFile = path } } +func WithLogFile(path string) option { + return func(c *config) { + c.LspLogFile = path + } +} + func WithDebug(debug bool) option { return func(c *config) { c.Debug = debug } } -func Setup(opts ...option) { +func Setup(opts ...option) error { cfg = new(config) for _, o := range opts { o(cfg) } + + if cfg.LogFile == "" { + return fmt.Errorf("pendulum activity log file option not set") + } + + if _, err := os.Stat(cfg.LogFile); err != nil && os.IsNotExist(err) { + f, err := os.Create(cfg.LogFile) + if err != nil { + return fmt.Errorf("pendulum activity log does not exist and could not be created") + } + if _, err := f.Write([]byte("active,branch,cwd,file,filetype,project,time\n")); err != nil { + return fmt.Errorf("failed to write pendulum header") + } + } + + return nil } func Config() *config { diff --git a/internal/handlers/logger.go b/internal/handlers/logger.go index c7a1c14..007a3a1 100644 --- a/internal/handlers/logger.go +++ b/internal/handlers/logger.go @@ -4,12 +4,15 @@ import ( "encoding/json" "fmt" "log" + "os" + "os/exec" + "regexp" + "strings" + "github.com/ptdewey/pendulum-server/internal/config" "github.com/tliron/glsp" ) -// TODO: - type activityData struct { Active bool `json:"active"` Branch string `json:"branch"` @@ -39,14 +42,62 @@ func LogActivity(ctx *glsp.Context, args []any) (bool, error) { return false, fmt.Errorf("failed to parse activity data: %w", err) } + ad.Project = getGitProject(ad.Cwd) + ad.Branch = getGitBranch(ad.Cwd) + row := ad.toCSV() - // TODO: write to csv - return false, nil + f, err := os.OpenFile(config.Config().LogFile, os.O_WRONLY|os.O_APPEND, 0664) + if err != nil { + return false, err + } + + if _, err := f.Write([]byte(row)); err != nil { + return false, err + } + + return true, nil +} + +func getGitBranch(cwd string) string { + cmd := exec.Command("git", "branch", "--show-current") + cmd.Dir = cwd + + output, err := cmd.Output() + if err != nil { + return "unknown_branch" + } + + branch := strings.TrimSpace(string(output)) + if branch == "" || strings.HasPrefix(branch, "fatal:") { + return "unknown_branch" + } + + return branch +} + +func getGitProject(cwd string) string { + cmd := exec.Command("git", "config", "--local", "remote.origin.url") + cmd.Dir = cwd + + output, err := cmd.Output() + if err != nil { + return "unknown_project" + } + + url := strings.TrimSpace(string(output)) + + re := regexp.MustCompile(`.*/([^.]+)\.git$`) + matches := re.FindStringSubmatch(url) + if len(matches) >= 2 { + return matches[1] + } + + return "unknown_project" } func (ad *activityData) toCSV() string { - return fmt.Sprintf("%t,%s,%s,%s,%s,%s,%s", + return fmt.Sprintf("%t,%s,%s,%s,%s,%s,%s\n", ad.Active, ad.Branch, ad.Cwd, diff --git a/justfile b/justfile index 58fe969..aa131d1 100644 --- a/justfile +++ b/justfile @@ -3,11 +3,13 @@ default: just --list fmt: - echo "Formatting lua/yankbank..." - stylua lua/ --config-path=.stylua.toml + @echo "Formatting lua/yankbank..." + @stylua lua/ --config-path=.stylua.toml + @echo "Formatting Go files in ./remote..." + @find ./remote -name '*.go' -exec gofmt -w {} + lint: - echo "Linting lua/yankbank..." - luacheck lua/ --globals vim + @echo "Linting lua/yankbank..." + @luacheck lua/ --globals vim pr-ready: fmt lint diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 266e9d5..b3a2db4 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -1,5 +1,7 @@ local M = {} +-- TODO: strip most of this code out into the LSP + local last_active_time = os.time() local active_flag = true local lsp_client = nil @@ -178,4 +180,3 @@ function M.setup(opts) end return M - diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index 9619b1c..4fea887 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -170,4 +170,3 @@ function M.setup(opts) end return M - diff --git a/main.go b/main.go index ae827d2..04088ea 100644 --- a/main.go +++ b/main.go @@ -13,13 +13,17 @@ import ( var name = "pendulum-server" func main() { + commonlog.Configure(1, &config.Config().LspLogFile) + // TODO: flags for on startup to set config vars - config.Setup( - config.WithLogFile("pendulum-log.csv"), - config.WithDebug(true), - ) - commonlog.Configure(1, &config.Config().LogFile) + if err := config.Setup( + config.WithActivityFile("pendulum-log.csv"), + config.WithDebug(true), + ); err != nil { + log.Println(err) + return + } h := protocol.Handler{ Initialize: lsp.Initialize, @@ -27,7 +31,7 @@ func main() { WorkspaceExecuteCommand: lsp.WorkspaceExecuteCommand, } - s := server.NewServer(&h, "pendulum-server", config.Config().Debug) + s := server.NewServer(&h, name, config.Config().Debug) if err := s.RunStdio(); err != nil { log.Println(err) From 0d11a23f0cc6d3fe0fcc93f4289533447ca53317 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Tue, 29 Jul 2025 21:54:04 -0400 Subject: [PATCH 06/20] refactor: move activity logic to go --- internal/config/config.go | 20 +++++ internal/handlers/activity.go | 150 ++++++++++++++++++++++++++++++++++ internal/handlers/logger.go | 98 +++++++++++++++++++--- internal/lsp/lsp.go | 18 +++- lua/pendulum/handlers.lua | 73 ++++++----------- 5 files changed, 297 insertions(+), 62 deletions(-) create mode 100644 internal/handlers/activity.go diff --git a/internal/config/config.go b/internal/config/config.go index c102542..6d7bcbc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "time" ) var cfg *config @@ -11,6 +12,8 @@ type config struct { LogFile string LspLogFile string Debug bool + TimeoutLen time.Duration + TimerLen time.Duration } type option func(c *config) @@ -33,8 +36,25 @@ func WithDebug(debug bool) option { } } +func WithTimeoutLen(timeout time.Duration) option { + return func(c *config) { + c.TimeoutLen = timeout + } +} + +func WithTimerLen(timer time.Duration) option { + return func(c *config) { + c.TimerLen = timer + } +} + func Setup(opts ...option) error { cfg = new(config) + + // Set defaults + cfg.TimeoutLen = 5 * time.Second + cfg.TimerLen = 1 * time.Second + for _, o := range opts { o(cfg) } diff --git a/internal/handlers/activity.go b/internal/handlers/activity.go new file mode 100644 index 0000000..3fca756 --- /dev/null +++ b/internal/handlers/activity.go @@ -0,0 +1,150 @@ +package handlers + +import ( + "context" + "log" + "os" + "sync" + "time" + + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/tliron/glsp" +) + +type ActivityManager struct { + mu sync.RWMutex + lastActiveTime time.Time + activeFlag bool + timeout time.Duration + interval time.Duration + cancel context.CancelFunc + ctx *glsp.Context + currentData *activityData +} + +var ( + activityManager *ActivityManager + managerMu sync.Mutex +) + +func GetActivityManager() *ActivityManager { + managerMu.Lock() + defer managerMu.Unlock() + return activityManager +} + +func InitializeActivityManager(ctx *glsp.Context, timeout, interval time.Duration) *ActivityManager { + managerMu.Lock() + defer managerMu.Unlock() + + if activityManager != nil { + activityManager.Stop() + } + + activityManager = &ActivityManager{ + lastActiveTime: time.Now(), + activeFlag: true, + timeout: timeout, + interval: interval, + ctx: ctx, + } + + activityManager.start() + return activityManager +} + +func (am *ActivityManager) start() { + ctx, cancel := context.WithCancel(context.Background()) + am.cancel = cancel + + go func() { + ticker := time.NewTicker(am.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + am.checkActiveStatus() + } + } + }() + + log.Printf("Activity manager started with timeout: %v, interval: %v", am.timeout, am.interval) +} + +func (am *ActivityManager) Stop() { + if am.cancel != nil { + am.cancel() + } +} + +func (am *ActivityManager) UpdateActivity() { + am.mu.Lock() + defer am.mu.Unlock() + am.lastActiveTime = time.Now() +} + +func (am *ActivityManager) SetCurrentData(data *activityData) { + am.mu.Lock() + defer am.mu.Unlock() + am.currentData = data +} + +func (am *ActivityManager) checkActiveStatus() { + am.mu.Lock() + defer am.mu.Unlock() + + now := time.Now() + isActive := now.Sub(am.lastActiveTime) < am.timeout + + // Log inactive period when transitioning from active to inactive + if !isActive && am.activeFlag { + am.activeFlag = false + if am.currentData != nil { + // Log the last active time as an active entry + data := *am.currentData + data.Active = true + data.Time = am.lastActiveTime.UTC().Format("2006-01-02 15:04:05") + am.logActivityData(&data) + } + } else if isActive && !am.activeFlag { + am.activeFlag = true + } + + // Always log current state if we have data + if am.currentData != nil { + data := *am.currentData + data.Active = isActive + data.Time = now.UTC().Format("2006-01-02 15:04:05") + am.logActivityData(&data) + } +} + +func (am *ActivityManager) logActivityData(data *activityData) { + if data.File == "" { + return + } + + // Enhance data with git info + data.Project = getGitProject(data.Cwd) + data.Branch = getGitBranch(data.Cwd) + + // Write to CSV + if err := writeActivityToCSV(data); err != nil { + log.Printf("Failed to write activity data: %v", err) + } +} + +func writeActivityToCSV(data *activityData) error { + f, err := os.OpenFile(config.Config().LogFile, os.O_WRONLY|os.O_APPEND, 0664) + if err != nil { + return err + } + defer f.Close() + + row := data.toCSV() + _, err = f.Write([]byte(row)) + return err +} diff --git a/internal/handlers/logger.go b/internal/handlers/logger.go index 007a3a1..3c85fe4 100644 --- a/internal/handlers/logger.go +++ b/internal/handlers/logger.go @@ -8,6 +8,7 @@ import ( "os/exec" "regexp" "strings" + "time" "github.com/ptdewey/pendulum-server/internal/config" "github.com/tliron/glsp" @@ -42,18 +43,34 @@ func LogActivity(ctx *glsp.Context, args []any) (bool, error) { return false, fmt.Errorf("failed to parse activity data: %w", err) } - ad.Project = getGitProject(ad.Cwd) - ad.Branch = getGitBranch(ad.Cwd) - - row := ad.toCSV() - - f, err := os.OpenFile(config.Config().LogFile, os.O_WRONLY|os.O_APPEND, 0664) - if err != nil { - return false, err - } - - if _, err := f.Write([]byte(row)); err != nil { - return false, err + am := GetActivityManager() + if am != nil { + // Update activity manager with current data + am.SetCurrentData(&ad) + am.UpdateActivity() + + // Immediately log this activity data + ad.Project = getGitProject(ad.Cwd) + ad.Branch = getGitBranch(ad.Cwd) + if err := writeActivityToCSV(&ad); err != nil { + log.Printf("Failed to write activity data: %v", err) + } + } else { + // Fallback to direct logging if manager not available + ad.Project = getGitProject(ad.Cwd) + ad.Branch = getGitBranch(ad.Cwd) + + row := ad.toCSV() + + f, err := os.OpenFile(config.Config().LogFile, os.O_WRONLY|os.O_APPEND, 0664) + if err != nil { + return false, err + } + defer f.Close() + + if _, err := f.Write([]byte(row)); err != nil { + return false, err + } } return true, nil @@ -107,3 +124,60 @@ func (ad *activityData) toCSV() string { ad.Time, ) } + +type sessionConfig struct { + TimeoutLen int `json:"timeout_len"` + TimerLen int `json:"timer_len"` +} + +func ActivityPing(ctx *glsp.Context, args []any) (bool, error) { + log.Printf("executing command 'pendulum.activityPing'") + + am := GetActivityManager() + if am == nil { + return false, fmt.Errorf("activity manager not initialized") + } + + am.UpdateActivity() + return true, nil +} + +func StartSession(ctx *glsp.Context, args []any) (bool, error) { + log.Printf("executing command 'pendulum.startSession' with args %v", args) + + // Default values + timeoutLen := 5 * time.Second + timerLen := 1 * time.Second + + // Parse config if provided + if len(args) > 0 { + jsonBytes, err := json.Marshal(args[0]) + if err == nil { + var cfg sessionConfig + if json.Unmarshal(jsonBytes, &cfg) == nil { + if cfg.TimeoutLen > 0 { + timeoutLen = time.Duration(cfg.TimeoutLen) * time.Second + } + if cfg.TimerLen > 0 { + timerLen = time.Duration(cfg.TimerLen) * time.Second + } + } + } + } + + InitializeActivityManager(ctx, timeoutLen, timerLen) + log.Printf("Activity session started with timeout: %v, timer: %v", timeoutLen, timerLen) + return true, nil +} + +func EndSession(ctx *glsp.Context, args []any) (bool, error) { + log.Printf("executing command 'pendulum.endSession'") + + am := GetActivityManager() + if am != nil { + am.Stop() + } + + log.Println("Activity session ended") + return true, nil +} diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index 28daca4..6fb6165 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -11,6 +11,9 @@ import ( const ( cmdLogActivity string = "pendulum.logActivity" + cmdActivityPing string = "pendulum.activityPing" + cmdStartSession string = "pendulum.startSession" + cmdEndSession string = "pendulum.endSession" cmdGenerateMetricsReport string = "pendulum.generateMetricsReport" cmdGenerateHourlyReport string = "pendulum.generateHourlyReport" ) @@ -19,7 +22,8 @@ func Initialize(ctx *glsp.Context, params *protocol.InitializeParams) (any, erro capabilities := protocol.ServerCapabilities{ ExecuteCommandProvider: &protocol.ExecuteCommandOptions{ Commands: []string{ - cmdLogActivity, cmdGenerateMetricsReport, cmdGenerateHourlyReport, + cmdLogActivity, cmdActivityPing, cmdStartSession, cmdEndSession, + cmdGenerateMetricsReport, cmdGenerateHourlyReport, }, }, } @@ -46,6 +50,12 @@ func Initialized(ctx *glsp.Context, params *protocol.InitializedParams) error { func Shutdown(ctx *glsp.Context) error { log.Println("pendulum-server shutting down") + + // Clean up activity manager + if am := handlers.GetActivityManager(); am != nil { + am.Stop() + } + return nil } @@ -53,6 +63,12 @@ func WorkspaceExecuteCommand(ctx *glsp.Context, params *protocol.ExecuteCommandP switch params.Command { case cmdLogActivity: return handlers.LogActivity(ctx, params.Arguments) + case cmdActivityPing: + return handlers.ActivityPing(ctx, params.Arguments) + case cmdStartSession: + return handlers.StartSession(ctx, params.Arguments) + case cmdEndSession: + return handlers.EndSession(ctx, params.Arguments) case cmdGenerateMetricsReport: return handlers.GenerateMetricsReport(ctx, params.Arguments) case cmdGenerateHourlyReport: diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index b3a2db4..5162951 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -1,27 +1,22 @@ local M = {} --- TODO: strip most of this code out into the LSP - -local last_active_time = os.time() -local active_flag = true local lsp_client = nil -local function update_activity() - last_active_time = os.time() -end - -local function send_to_lsp(activity_data) +local function send_to_lsp(command, args) if not lsp_client or lsp_client.is_stopped() then return end lsp_client.request("workspace/executeCommand", { - command = "pendulum.logActivity", - arguments = { activity_data }, + command = command, + arguments = args and { args } or {}, }, function(err, _) if err then vim.notify( - "Failed to log activity: " .. tostring(err.message or err), + "Failed to execute " + .. command + .. ": " + .. tostring(err.message or err), vim.log.levels.ERROR ) end @@ -33,7 +28,6 @@ local function init_lsp_client(opts) return lsp_client end - -- Check if the binary exists and is executable local binary_path = opts.lsp_binary local stat = vim.loop.fs_stat(binary_path) @@ -60,6 +54,10 @@ local function init_lsp_client(opts) filetypes = {}, on_attach = function(client, bufnr) vim.lsp.log.debug("Pendulum LSP attached") + send_to_lsp("pendulum.startSession", { + timeout_len = opts.timeout_len, + timer_len = opts.timer_len, + }) end, on_exit = function(code, signal, _) lsp_client = nil @@ -90,37 +88,26 @@ local function init_lsp_client(opts) return lsp_client end -local function log_activity(is_active, active_time) - local time = active_time or os.time() +local function ping_activity() + send_to_lsp("pendulum.activityPing") +end + +local function log_full_activity() local ft = vim.bo.filetype ~= "" and vim.bo.filetype or "unknown_filetype" local data = { - time = os.date("!%Y-%m-%d %H:%M:%S", time), - active = is_active, + time = os.date("!%Y-%m-%d %H:%M:%S"), + active = true, file = vim.fn.expand("%:p"), filetype = ft, cwd = vim.loop.cwd(), } if data.file ~= "" then - send_to_lsp(data) - end - - return data -end - -local function check_active_status(opts) - local is_active = os.time() - last_active_time < opts.timeout_len - if not is_active and active_flag then - active_flag = false - log_activity(true, last_active_time) - elseif is_active and not active_flag then - active_flag = true + send_to_lsp("pendulum.logActivity", data) end - log_activity(is_active) end --- Expose the LSP client to other modules function M.get_lsp_client() return lsp_client end @@ -139,44 +126,32 @@ function M.setup(opts) return end - update_activity() - vim.api.nvim_create_augroup("Pendulum", { clear = true }) vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { group = "Pendulum", - callback = update_activity, + callback = ping_activity, }) vim.api.nvim_create_autocmd({ "BufEnter" }, { group = "Pendulum", - callback = function() - log_activity(true) - end, + callback = log_full_activity, }) vim.api.nvim_create_autocmd({ "VimLeave" }, { group = "Pendulum", callback = function() if lsp_client and not lsp_client.is_stopped() then - log_activity(true) + log_full_activity() + send_to_lsp("pendulum.endSession") end end, }) - -- Initialize LSP client immediately (deferred to next tick) + -- Initialize LSP client immediately (deferred to next tick to avoid start-up error) vim.defer_fn(function() init_lsp_client(opts) end, 0) - - -- Start the activity checking timer - vim.defer_fn(function() - vim.fn.timer_start(opts.timer_len * 1000, function() - vim.schedule(function() - check_active_status(opts) - end) - end, { ["repeat"] = -1 }) - end, 100) end return M From 162cda29180e49801c012cc075b9afe35bcdbcf9 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Tue, 29 Jul 2025 21:54:46 -0400 Subject: [PATCH 07/20] wip lsp config flags --- internal/handlers/logger.go | 28 ++++++++++++++++---------- internal/lsp/lsp.go | 8 ++++++++ lua/pendulum/handlers.lua | 12 ++++++----- main.go | 40 +++++++++++++++++++++++++++++++++---- 4 files changed, 69 insertions(+), 19 deletions(-) diff --git a/internal/handlers/logger.go b/internal/handlers/logger.go index 3c85fe4..068023f 100644 --- a/internal/handlers/logger.go +++ b/internal/handlers/logger.go @@ -145,26 +145,34 @@ func ActivityPing(ctx *glsp.Context, args []any) (bool, error) { func StartSession(ctx *glsp.Context, args []any) (bool, error) { log.Printf("executing command 'pendulum.startSession' with args %v", args) - // Default values - timeoutLen := 5 * time.Second - timerLen := 1 * time.Second + // Start with CLI config defaults + cfg := config.Config() + timeoutLen := cfg.TimeoutLen + timerLen := cfg.TimerLen - // Parse config if provided + // Allow Lua to override with session-specific config if len(args) > 0 { jsonBytes, err := json.Marshal(args[0]) if err == nil { - var cfg sessionConfig - if json.Unmarshal(jsonBytes, &cfg) == nil { - if cfg.TimeoutLen > 0 { - timeoutLen = time.Duration(cfg.TimeoutLen) * time.Second + var sessionCfg sessionConfig + if json.Unmarshal(jsonBytes, &sessionCfg) == nil { + if sessionCfg.TimeoutLen > 0 { + timeoutLen = time.Duration(sessionCfg.TimeoutLen) * time.Second + log.Printf("Using Lua-provided timeout: %v", timeoutLen) } - if cfg.TimerLen > 0 { - timerLen = time.Duration(cfg.TimerLen) * time.Second + if sessionCfg.TimerLen > 0 { + timerLen = time.Duration(sessionCfg.TimerLen) * time.Second + log.Printf("Using Lua-provided timer: %v", timerLen) } } } } + // Stop existing manager if any + if am := GetActivityManager(); am != nil { + am.Stop() + } + InitializeActivityManager(ctx, timeoutLen, timerLen) log.Printf("Activity session started with timeout: %v, timer: %v", timeoutLen, timerLen) return true, nil diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index 6fb6165..9bd32a3 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -4,6 +4,7 @@ import ( "fmt" "log" + "github.com/ptdewey/pendulum-server/internal/config" "github.com/ptdewey/pendulum-server/internal/handlers" "github.com/tliron/glsp" protocol "github.com/tliron/glsp/protocol_3_16" @@ -45,6 +46,13 @@ func Initialized(ctx *glsp.Context, params *protocol.InitializedParams) error { Message: "pendulum-server is ready", }) + // Auto-start activity manager with CLI config + cfg := config.Config() + if cfg != nil { + handlers.InitializeActivityManager(ctx, cfg.TimeoutLen, cfg.TimerLen) + log.Printf("Activity manager auto-started with timeout: %v, interval: %v", cfg.TimeoutLen, cfg.TimerLen) + } + return nil } diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 5162951..8723806 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -49,15 +49,17 @@ local function init_lsp_client(opts) local client_id = vim.lsp.start({ name = "pendulum-lsp", - cmd = { binary_path, "--csv-path", opts.log_file }, + cmd = { + binary_path, + "--csv-path", opts.log_file, + "--activity-timeout", tostring(opts.timeout_len), + "--check-interval", tostring(opts.timer_len) + }, root_dir = vim.loop.cwd(), filetypes = {}, on_attach = function(client, bufnr) vim.lsp.log.debug("Pendulum LSP attached") - send_to_lsp("pendulum.startSession", { - timeout_len = opts.timeout_len, - timer_len = opts.timer_len, - }) + -- Activity manager is auto-started by LSP with CLI config end, on_exit = function(code, signal, _) lsp_client = nil diff --git a/main.go b/main.go index 04088ea..62de31b 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,10 @@ package main import ( + "flag" + "fmt" "log" + "time" "github.com/ptdewey/pendulum-server/internal/config" "github.com/ptdewey/pendulum-server/internal/lsp" @@ -13,21 +16,50 @@ import ( var name = "pendulum-server" func main() { - commonlog.Configure(1, &config.Config().LspLogFile) + // Parse command line flags + var ( + csvPath = flag.String("csv-path", "pendulum-log.csv", "Path to CSV activity log file") + activityTimeout = flag.Int("activity-timeout", 5, "Activity timeout in seconds (how long to wait before marking as inactive)") + checkInterval = flag.Int("check-interval", 1, "Activity check interval in seconds (how often to check activity status)") + debug = flag.Bool("debug", false, "Enable debug mode with verbose logging") + help = flag.Bool("help", false, "Show this help information") + ) + + flag.Usage = func() { + fmt.Printf("Usage: %s [options]\n\n", name) + fmt.Println("Pendulum LSP server for activity tracking in Neovim") + fmt.Println("\nOptions:") + flag.PrintDefaults() + fmt.Println("\nExamples:") + fmt.Printf(" %s --csv-path activity.csv\n", name) + fmt.Printf(" %s --csv-path logs/activity.csv --activity-timeout 10 --check-interval 2\n", name) + fmt.Printf(" %s --debug --activity-timeout 3\n", name) + } + + flag.Parse() - // TODO: flags for on startup to set config vars + if *help { + flag.Usage() + return + } + // Setup configuration with CLI flags if err := config.Setup( - config.WithActivityFile("pendulum-log.csv"), - config.WithDebug(true), + config.WithActivityFile(*csvPath), + config.WithTimeoutLen(time.Duration(*activityTimeout)*time.Second), + config.WithTimerLen(time.Duration(*checkInterval)*time.Second), + config.WithDebug(*debug), ); err != nil { log.Println(err) return } + commonlog.Configure(1, &config.Config().LspLogFile) + h := protocol.Handler{ Initialize: lsp.Initialize, Initialized: lsp.Initialized, + Shutdown: lsp.Shutdown, WorkspaceExecuteCommand: lsp.WorkspaceExecuteCommand, } From 64c182917ca50a42bd96d4c1e2594ce140516b7d Mon Sep 17 00:00:00 2001 From: ptdewey Date: Wed, 30 Jul 2025 20:11:59 -0400 Subject: [PATCH 08/20] lsp updates --- internal/config/config.go | 4 ---- internal/handlers/activity.go | 2 -- internal/handlers/logger.go | 29 ----------------------------- lua/pendulum/handlers.lua | 27 ++++++++++++++------------- main.go | 34 +++++++--------------------------- 5 files changed, 21 insertions(+), 75 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 6d7bcbc..d80a87c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,10 +51,6 @@ func WithTimerLen(timer time.Duration) option { func Setup(opts ...option) error { cfg = new(config) - // Set defaults - cfg.TimeoutLen = 5 * time.Second - cfg.TimerLen = 1 * time.Second - for _, o := range opts { o(cfg) } diff --git a/internal/handlers/activity.go b/internal/handlers/activity.go index 3fca756..cae31cd 100644 --- a/internal/handlers/activity.go +++ b/internal/handlers/activity.go @@ -127,11 +127,9 @@ func (am *ActivityManager) logActivityData(data *activityData) { return } - // Enhance data with git info data.Project = getGitProject(data.Cwd) data.Branch = getGitBranch(data.Cwd) - // Write to CSV if err := writeActivityToCSV(data); err != nil { log.Printf("Failed to write activity data: %v", err) } diff --git a/internal/handlers/logger.go b/internal/handlers/logger.go index 068023f..d0f4cd8 100644 --- a/internal/handlers/logger.go +++ b/internal/handlers/logger.go @@ -8,7 +8,6 @@ import ( "os/exec" "regexp" "strings" - "time" "github.com/ptdewey/pendulum-server/internal/config" "github.com/tliron/glsp" @@ -24,10 +23,7 @@ type activityData struct { Time string `json:"time"` } -// TODO: output should just be error func LogActivity(ctx *glsp.Context, args []any) (bool, error) { - log.Printf("executing command 'pendulum.logActivity' with args %v (%T)", args, args) - if len(args) == 0 { return false, fmt.Errorf("no arguments provided") } @@ -125,14 +121,7 @@ func (ad *activityData) toCSV() string { ) } -type sessionConfig struct { - TimeoutLen int `json:"timeout_len"` - TimerLen int `json:"timer_len"` -} - func ActivityPing(ctx *glsp.Context, args []any) (bool, error) { - log.Printf("executing command 'pendulum.activityPing'") - am := GetActivityManager() if am == nil { return false, fmt.Errorf("activity manager not initialized") @@ -150,24 +139,6 @@ func StartSession(ctx *glsp.Context, args []any) (bool, error) { timeoutLen := cfg.TimeoutLen timerLen := cfg.TimerLen - // Allow Lua to override with session-specific config - if len(args) > 0 { - jsonBytes, err := json.Marshal(args[0]) - if err == nil { - var sessionCfg sessionConfig - if json.Unmarshal(jsonBytes, &sessionCfg) == nil { - if sessionCfg.TimeoutLen > 0 { - timeoutLen = time.Duration(sessionCfg.TimeoutLen) * time.Second - log.Printf("Using Lua-provided timeout: %v", timeoutLen) - } - if sessionCfg.TimerLen > 0 { - timerLen = time.Duration(sessionCfg.TimerLen) * time.Second - log.Printf("Using Lua-provided timer: %v", timerLen) - } - } - } - } - // Stop existing manager if any if am := GetActivityManager(); am != nil { am.Stop() diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 8723806..7aa4827 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -28,20 +28,19 @@ local function init_lsp_client(opts) return lsp_client end - local binary_path = opts.lsp_binary - local stat = vim.loop.fs_stat(binary_path) + local stat = vim.loop.fs_stat(opts.lsp_binary) if not stat then vim.notify( - "Pendulum LSP binary not found: " .. binary_path, + "Pendulum LSP binary not found: " .. opts.lsp_binary, vim.log.levels.ERROR ) return nil end - if not vim.fn.executable(binary_path) then + if not vim.fn.executable(opts.lsp_binary) then vim.notify( - "Pendulum LSP binary is not executable: " .. binary_path, + "Pendulum LSP binary is not executable: " .. opts.lsp_binary, vim.log.levels.ERROR ) return nil @@ -49,17 +48,19 @@ local function init_lsp_client(opts) local client_id = vim.lsp.start({ name = "pendulum-lsp", - cmd = { - binary_path, - "--csv-path", opts.log_file, - "--activity-timeout", tostring(opts.timeout_len), - "--check-interval", tostring(opts.timer_len) + cmd = { + opts.binary_path, + "--csv-path", + opts.log_file, + "--activity-timeout", + tostring(opts.timeout_len), + "--check-interval", + tostring(opts.timer_len), }, root_dir = vim.loop.cwd(), filetypes = {}, on_attach = function(client, bufnr) vim.lsp.log.debug("Pendulum LSP attached") - -- Activity manager is auto-started by LSP with CLI config end, on_exit = function(code, signal, _) lsp_client = nil @@ -69,7 +70,7 @@ local function init_lsp_client(opts) tostring(code), tostring(signal) ), - vim.log.levels.WARN + vim.log.levels.ERROR ) end, }) @@ -82,7 +83,7 @@ local function init_lsp_client(opts) ) else vim.notify( - "Failed to start Pendulum LSP server: " .. binary_path, + "Failed to start Pendulum LSP server: " .. opts.lsp_binary, vim.log.levels.ERROR ) end diff --git a/main.go b/main.go index 62de31b..bfc5b89 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "flag" - "fmt" "log" "time" @@ -15,35 +14,16 @@ import ( var name = "pendulum-server" -func main() { - // Parse command line flags - var ( - csvPath = flag.String("csv-path", "pendulum-log.csv", "Path to CSV activity log file") - activityTimeout = flag.Int("activity-timeout", 5, "Activity timeout in seconds (how long to wait before marking as inactive)") - checkInterval = flag.Int("check-interval", 1, "Activity check interval in seconds (how often to check activity status)") - debug = flag.Bool("debug", false, "Enable debug mode with verbose logging") - help = flag.Bool("help", false, "Show this help information") - ) - - flag.Usage = func() { - fmt.Printf("Usage: %s [options]\n\n", name) - fmt.Println("Pendulum LSP server for activity tracking in Neovim") - fmt.Println("\nOptions:") - flag.PrintDefaults() - fmt.Println("\nExamples:") - fmt.Printf(" %s --csv-path activity.csv\n", name) - fmt.Printf(" %s --csv-path logs/activity.csv --activity-timeout 10 --check-interval 2\n", name) - fmt.Printf(" %s --debug --activity-timeout 3\n", name) - } +var ( + csvPath = flag.String("csv-path", "pendulum-log.csv", "Path to CSV activity log file") + activityTimeout = flag.Int("activity-timeout", 180, "Activity timeout in seconds (how long to wait before marking as inactive)") + checkInterval = flag.Int("check-interval", 90, "Activity check interval in seconds (how often to check activity status)") + debug = flag.Bool("debug", false, "Enable debug mode with verbose logging") +) +func main() { flag.Parse() - if *help { - flag.Usage() - return - } - - // Setup configuration with CLI flags if err := config.Setup( config.WithActivityFile(*csvPath), config.WithTimeoutLen(time.Duration(*activityTimeout)*time.Second), From d2fe9eb36b5a3a17c3873e355daef70c484ef0a5 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Wed, 30 Jul 2025 20:43:05 -0400 Subject: [PATCH 09/20] fix: handler opt binary path --- lua/pendulum/handlers.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 7aa4827..264a4b3 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -49,7 +49,7 @@ local function init_lsp_client(opts) local client_id = vim.lsp.start({ name = "pendulum-lsp", cmd = { - opts.binary_path, + opts.lsp_binary, "--csv-path", opts.log_file, "--activity-timeout", From c38baf649edf178f2e4e1b0fabd153e7f20d52a2 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Wed, 30 Jul 2025 21:54:24 -0400 Subject: [PATCH 10/20] feat: metrics popup --- .github/FUNDING.yml | 1 - Cargo.lock | 1237 --------------------- Cargo.toml | 16 - README.md | 2 +- flake.lock | 26 - flake.nix | 24 - go.mod | 6 +- internal/handlers/data/aggregator.go | 274 +++++ internal/handlers/data/aggregator_test.go | 234 ++++ internal/handlers/data/csv.go | 135 +++ internal/handlers/data/csv_test.go | 150 +++ internal/handlers/data/types.go | 79 ++ internal/handlers/data/utils.go | 167 +++ internal/handlers/data/utils_test.go | 227 ++++ internal/handlers/metrics.go | 102 +- internal/handlers/metrics_test.go | 286 +++++ internal/handlers/params/parser.go | 159 +++ internal/handlers/params/parser_test.go | 265 +++++ internal/handlers/prettify/formatter.go | 220 ++++ lua/pendulum/remote.lua | 27 +- remote/internal/prettify/metrics.go | 7 +- src/main.rs | 251 ----- 22 files changed, 2305 insertions(+), 1590 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml delete mode 100644 flake.lock delete mode 100644 flake.nix create mode 100644 internal/handlers/data/aggregator.go create mode 100644 internal/handlers/data/aggregator_test.go create mode 100644 internal/handlers/data/csv.go create mode 100644 internal/handlers/data/csv_test.go create mode 100644 internal/handlers/data/types.go create mode 100644 internal/handlers/data/utils.go create mode 100644 internal/handlers/data/utils_test.go create mode 100644 internal/handlers/metrics_test.go create mode 100644 internal/handlers/params/parser.go create mode 100644 internal/handlers/params/parser_test.go create mode 100644 internal/handlers/prettify/formatter.go delete mode 100644 src/main.rs diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 70444a1..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: ptdewey diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 4124d38..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1237 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "0.6.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "anyhow" -version = "1.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" - -[[package]] -name = "async-trait" -version = "0.1.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "auto_impl" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "cfg-if" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" - -[[package]] -name = "clap" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "csv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "csv-core" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" -dependencies = [ - "memchr", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "env_filter" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "jiff" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde", -] - -[[package]] -name = "jiff-static" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "libc" -version = "0.2.174" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "lsp-types" -version = "0.94.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" -dependencies = [ - "bitflags 1.3.2", - "serde", - "serde_json", - "serde_repr", - "url", -] - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets", -] - -[[package]] -name = "pendulum-lsp" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "csv", - "env_logger", - "log", - "regex", - "serde", - "serde_json", - "tokio", - "tower-lsp", -] - -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" -dependencies = [ - "zerovec", -] - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7251471db004e509f4e75a62cca9435365b5ec7bcdff530d612ac7c87c44a792" -dependencies = [ - "bitflags 2.9.1", -] - -[[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" - -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.141" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "slab", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-util" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-lsp" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" -dependencies = [ - "async-trait", - "auto_impl", - "bytes", - "dashmap", - "futures", - "httparse", - "lsp-types", - "memchr", - "serde", - "serde_json", - "tokio", - "tokio-util", - "tower", - "tower-lsp-macros", - "tracing", -] - -[[package]] -name = "tower-lsp-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "url" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 521f595..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "pendulum-lsp" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = "1.0.98" -csv = "1.3.1" -env_logger = "0.11.8" -log = "0.4.27" -clap = { version = "4.0", features = ["derive"] } -regex = "1.11.1" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.141" -tokio = { version = "1.47.0", features = ["full"] } -tower-lsp = "0.20.0" diff --git a/README.md b/README.md index 776d1fb..f4a1a19 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ The metrics report contents are customizable and section items or entire section These are some potential future ideas that would make for welcome contributions for anyone interested. - Logging to SQLite database (optionally) -- Telescope integration +- Fuzzy finder integration - Get stats for specified project, filetype, etc. (Could work well with Telescope) - Nicer looking popup with custom highlight groups - Alternative version of popup that uses a terminal buffer and [bubbletea](https://github.com/charmbracelet/bubbletea) (using the table component) diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 3f4f031..0000000 --- a/flake.lock +++ /dev/null @@ -1,26 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1753432016, - "narHash": "sha256-cnL5WWn/xkZoyH/03NNUS7QgW5vI7D1i74g48qplCvg=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "6027c30c8e9810896b92429f0092f624f7b1aace", - "type": "github" - }, - "original": { - "id": "nixpkgs", - "ref": "nixpkgs-unstable", - "type": "indirect" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index aead6e8..0000000 --- a/flake.nix +++ /dev/null @@ -1,24 +0,0 @@ -{ - description = "Rust dev shell flake"; - inputs = { - nixpkgs.url = "nixpkgs/nixpkgs-unstable"; - }; - outputs = { nixpkgs, ... }: let - forAllSystems = function: - nixpkgs.lib.genAttrs [ - "x86_64-linux" - "aarch64-linux" - ] (system: - function nixpkgs.legacyPackages.${system} - ); - in { - devShells = forAllSystems(pkgs: { - default = pkgs.mkShell { - packages = with pkgs; [ - openssl - pkg-config - ]; - }; - }); - }; -} diff --git a/go.mod b/go.mod index 086a514..b9dcfa0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/ptdewey/pendulum-server go 1.24.4 -require github.com/tliron/glsp v0.2.2 +require ( + github.com/tliron/commonlog v0.2.8 + github.com/tliron/glsp v0.2.2 +) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -17,7 +20,6 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect - github.com/tliron/commonlog v0.2.8 // indirect github.com/tliron/kutil v0.3.11 // indirect golang.org/x/crypto v0.15.0 // indirect golang.org/x/net v0.17.0 // indirect diff --git a/internal/handlers/data/aggregator.go b/internal/handlers/data/aggregator.go new file mode 100644 index 0000000..c51067d --- /dev/null +++ b/internal/handlers/data/aggregator.go @@ -0,0 +1,274 @@ +package data + +import ( + "context" + "log" + "regexp" + "runtime" + "strconv" + "sync" + "time" +) + +// MetricsAggregator handles concurrent aggregation of pendulum metrics +type MetricsAggregator struct { + workerCount int + params *MetricsParams +} + +// NewMetricsAggregator creates a new metrics aggregator +func NewMetricsAggregator(params *MetricsParams) *MetricsAggregator { + workerCount := min(runtime.NumCPU(), 8) // Cap at 8 workers for memory efficiency + + return &MetricsAggregator{ + workerCount: workerCount, + params: params, + } +} + +// AggregatePendulumMetrics aggregates metrics using concurrent workers +func (a *MetricsAggregator) AggregatePendulumMetrics(ctx context.Context, data [][]string) (*ProcessingResult, error) { + startTime := time.Now() + + if len(data) == 0 { + return &ProcessingResult{ + Metrics: []PendulumMetric{}, + Processed: 0, + Duration: time.Since(startTime), + }, nil + } + + // Create exclude maps + excludeMap := make(map[int]struct{}) + for _, section := range a.params.ReportSectionExcludes { + if idx, exists := CSVColumns[section]; exists { + excludeMap[idx] = struct{}{} + } + } + + // Create work channels + jobs := make(chan aggregationJob, len(CSVColumns)) + results := make(chan PendulumMetric, len(CSVColumns)) + errors := make(chan error, len(CSVColumns)) + + // Start workers + var wg sync.WaitGroup + for i := 0; i < a.workerCount; i++ { + wg.Add(1) + go a.worker(ctx, &wg, jobs, results, errors) + } + + // Send jobs + go func() { + defer close(jobs) + for colName, colIdx := range CSVColumns { + if colName == "active" || colName == "time" { + continue + } + if _, excluded := excludeMap[colIdx]; excluded { + continue + } + + select { + case jobs <- aggregationJob{ + data: data, + colIndex: colIdx, + colName: colName, + }: + case <-ctx.Done(): + return + } + } + }() + + // Close results when all workers are done + go func() { + wg.Wait() + close(results) + close(errors) + }() + + // Collect results + metrics := make([]PendulumMetric, len(CSVColumns)) + var firstError error + + for { + select { + case result, ok := <-results: + if !ok { + // Channel closed, we're done + goto done + } + metrics[result.Index] = result + + case err := <-errors: + if firstError == nil { + firstError = err + } + log.Printf("Worker error: %v", err) + + case <-ctx.Done(): + return nil, &MetricsError{ + Type: ErrProcessingTimeout, + Message: "metrics aggregation cancelled", + Cause: ctx.Err(), + } + } + } + +done: + if firstError != nil { + return nil, firstError + } + + // Filter out empty metrics + var filteredMetrics []PendulumMetric + for _, metric := range metrics { + if metric.Name != "" && len(metric.Value) > 0 { + filteredMetrics = append(filteredMetrics, metric) + } + } + + return &ProcessingResult{ + Metrics: filteredMetrics, + Processed: len(data) - 1, // Exclude header + Duration: time.Since(startTime), + }, nil +} + +type aggregationJob struct { + data [][]string + colIndex int + colName string +} + +// worker processes aggregation jobs +func (a *MetricsAggregator) worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan aggregationJob, results chan<- PendulumMetric, errors chan<- error) { + defer wg.Done() + + for job := range jobs { + select { + case <-ctx.Done(): + return + default: + } + + metric, err := a.aggregateMetric(job.data, job.colIndex, job.colName) + if err != nil { + errors <- err + continue + } + + results <- metric + } +} + +// aggregateMetric aggregates a single metric column +func (a *MetricsAggregator) aggregateMetric(data [][]string, colIdx int, colName string) (PendulumMetric, error) { + metric := PendulumMetric{ + Name: data[0][colIdx], + Index: colIdx, + Value: make(map[string]*PendulumEntry), + } + + timecol := CSVColumns["time"] + + // Handle cwd vs directory naming inconsistency + filterColName := colName + if colName == "cwd" { + filterColName = "directory" + } + + // Compile exclusion patterns + var exclusionPatterns []*regexp.Regexp + if filters, exists := a.params.ReportExcludes[filterColName]; exists { + var err error + exclusionPatterns, err = CompileRegexPatterns(filters) + if err != nil { + return metric, err + } + } + + // Process each row + for i := 1; i < len(data); i++ { + if len(data[i]) <= colIdx || len(data[i]) <= timecol { + continue // Skip malformed rows + } + + active, err := strconv.ParseBool(data[i][0]) + if err != nil { + log.Printf("Error parsing boolean at row %d, value: %s, error: %v", i, data[i][0], err) + continue + } + + // Check time range + if a.params.TimeRange != "all" { + inRange, err := IsTimestampInRange(data[i][timecol], a.params.TimeRange, a.params.TimeZone) + if err != nil { + log.Printf("Error checking timestamp range: %v", err) + continue + } + if !inRange { + continue + } + } + + val := data[i][colIdx] + if IsExcluded(val, exclusionPatterns) { + continue + } + + // Initialize entry if doesn't exist + if metric.Value[val] == nil { + metric.Value[val] = &PendulumEntry{ + ID: val, + ActiveCount: 0, + TotalCount: 0, + ActiveTime: 0, + TotalTime: 0, + Timestamps: make([]string, 0), + ActiveTimestamps: make([]string, 0), + ActivePct: 0, + } + } + entry := metric.Value[val] + + // Update total metrics + entry.updateTotalMetrics(data[i][timecol], a.params.TimeoutLen) + + // Update active metrics if active + if active { + entry.updateActiveMetrics(data[i][timecol], a.params.TimeoutLen) + } + } + + // Calculate active percentages + a.calculateActivePercentages(metric.Value) + + return metric, nil +} + +// updateTotalMetrics updates total count and time for an entry +func (entry *PendulumEntry) updateTotalMetrics(timestampStr string, timeoutLen float64) { + entry.Timestamps = append(entry.Timestamps, timestampStr) + tt, _ := TimeDiff(entry.Timestamps, timeoutLen, false) + entry.TotalCount++ + entry.TotalTime += tt +} + +// updateActiveMetrics updates active count and time for an entry +func (entry *PendulumEntry) updateActiveMetrics(timestampStr string, timeoutLen float64) { + entry.ActiveTimestamps = append(entry.ActiveTimestamps, timestampStr) + at, _ := TimeDiff(entry.ActiveTimestamps, timeoutLen, false) + entry.ActiveCount++ + entry.ActiveTime += at +} + +// calculateActivePercentages calculates the active percentage for all entries +func (a *MetricsAggregator) calculateActivePercentages(values map[string]*PendulumEntry) { + for _, v := range values { + if v.TotalTime > 0 { + v.ActivePct = float64(v.ActiveTime) / float64(v.TotalTime) + } + } +} diff --git a/internal/handlers/data/aggregator_test.go b/internal/handlers/data/aggregator_test.go new file mode 100644 index 0000000..ca718e2 --- /dev/null +++ b/internal/handlers/data/aggregator_test.go @@ -0,0 +1,234 @@ +package data + +import ( + "context" + "fmt" + "testing" +) + +func TestNewMetricsAggregator(t *testing.T) { + params := &MetricsParams{ + LogFile: "test.csv", + TopN: 5, + TimeRange: "all", + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: []string{}, + TimeZone: "UTC", + TimeoutLen: 180.0, + } + + aggregator := NewMetricsAggregator(params) + + if aggregator.params != params { + t.Error("Expected params to be set correctly") + } + + if aggregator.workerCount <= 0 { + t.Error("Expected positive worker count") + } +} + +func TestMetricsAggregator_AggregatePendulumMetrics(t *testing.T) { + params := &MetricsParams{ + TopN: 5, + TimeRange: "all", + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: []string{}, + TimeZone: "UTC", + TimeoutLen: 180.0, + } + + aggregator := NewMetricsAggregator(params) + + tests := []struct { + name string + data [][]string + expectError bool + expectCount int + }{ + { + name: "empty data", + data: [][]string{}, + expectError: false, + expectCount: 0, + }, + { + name: "header only", + data: [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + }, + expectError: false, + expectCount: 0, + }, + { + name: "normal data", + data: [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + {"true", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:00:00"}, + {"false", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:01:00"}, + {"true", "feature", "/home/user", "main.go", "go", "myproject", "2024-01-01 10:02:00"}, + }, + expectError: false, + expectCount: 5, // branch, directory, file, filetype, project + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + result, err := aggregator.AggregatePendulumMetrics(ctx, tt.data) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(result.Metrics) != tt.expectCount { + t.Errorf("Expected %d metrics, got %d", tt.expectCount, len(result.Metrics)) + } + + if len(tt.data) > 1 { + expectedProcessed := len(tt.data) - 1 // Exclude header + if result.Processed != expectedProcessed { + t.Errorf("Expected %d processed rows, got %d", expectedProcessed, result.Processed) + } + } + }) + } +} + +func TestMetricsAggregator_WithExclusions(t *testing.T) { + params := &MetricsParams{ + TopN: 5, + TimeRange: "all", + TimeZone: "UTC", + TimeoutLen: 180.0, + ReportExcludes: map[string][]string{ + "file": {"test.*"}, + }, + ReportSectionExcludes: []string{"branch"}, + } + + aggregator := NewMetricsAggregator(params) + + data := [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + {"true", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:00:00"}, + {"true", "main", "/home/user", "main.go", "go", "myproject", "2024-01-01 10:01:00"}, + } + + ctx := context.Background() + result, err := aggregator.AggregatePendulumMetrics(ctx, data) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Should exclude branch section entirely + for _, metric := range result.Metrics { + if metric.Name == "branch" { + t.Error("Expected branch metric to be excluded") + } + + // For file metric, should exclude test.go but include main.go + if metric.Name == "file" { + if _, exists := metric.Value["test.go"]; exists { + t.Error("Expected test.go to be excluded") + } + if _, exists := metric.Value["main.go"]; !exists { + t.Error("Expected main.go to be included") + } + } + } +} + +func TestMetricsAggregator_ContextCancellation(t *testing.T) { + params := &MetricsParams{ + TopN: 5, + TimeRange: "all", + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: []string{}, + TimeZone: "UTC", + TimeoutLen: 180.0, + } + + aggregator := NewMetricsAggregator(params) + + data := [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + {"true", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:00:00"}, + } + + // Cancel context immediately + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := aggregator.AggregatePendulumMetrics(ctx, data) + + if err == nil { + t.Error("Expected error for cancelled context") + } +} + +func BenchmarkMetricsAggregation(b *testing.B) { + params := &MetricsParams{ + TopN: 5, + TimeRange: "all", + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: []string{}, + TimeZone: "UTC", + TimeoutLen: 180.0, + } + + // Create test data with different sizes + sizes := []int{100, 1000, 10000} + + for _, size := range sizes { + b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) { + data := generateTestData(size) + aggregator := NewMetricsAggregator(params) + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := aggregator.AggregatePendulumMetrics(ctx, data) + if err != nil { + b.Fatalf("Aggregation failed: %v", err) + } + } + }) + } +} + +// generateTestData creates test CSV data for benchmarking +func generateTestData(size int) [][]string { + data := [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + } + + for i := 0; i < size; i++ { + active := "true" + if i%2 == 0 { + active = "false" + } + + row := []string{ + active, + "main", + "/home/user", + "test.go", + "go", + "myproject", + "2024-01-01 10:00:00", + } + data = append(data, row) + } + + return data +} diff --git a/internal/handlers/data/csv.go b/internal/handlers/data/csv.go new file mode 100644 index 0000000..9dbe001 --- /dev/null +++ b/internal/handlers/data/csv.go @@ -0,0 +1,135 @@ +package data + +import ( + "bufio" + "context" + "encoding/csv" + "os" +) + +// CSVReader provides enhanced CSV reading capabilities +type CSVReader struct { + filepath string +} + +// NewCSVReader creates a new CSV reader for the given file +func NewCSVReader(filepath string) *CSVReader { + return &CSVReader{filepath: filepath} +} + +// ReadAll reads the entire CSV file into memory +func (r *CSVReader) ReadAll() ([][]string, error) { + f, err := os.Open(r.filepath) + if err != nil { + return nil, &MetricsError{ + Type: ErrFileNotFound, + Message: "failed to open pendulum log file", + Cause: err, + } + } + defer f.Close() + + csvReader := csv.NewReader(f) + data, err := csvReader.ReadAll() + if err != nil { + return nil, &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to parse CSV data", + Cause: err, + } + } + + return data, nil +} + +// StreamRows streams CSV rows one by one with context cancellation support +func (r *CSVReader) StreamRows(ctx context.Context, processor func([]string) error) error { + f, err := os.Open(r.filepath) + if err != nil { + return &MetricsError{ + Type: ErrFileNotFound, + Message: "failed to open pendulum log file", + Cause: err, + } + } + defer f.Close() + + csvReader := csv.NewReader(bufio.NewReader(f)) + + // Read header + header, err := csvReader.Read() + if err != nil { + return &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to read CSV header", + Cause: err, + } + } + + // Process header + if err := processor(header); err != nil { + return err + } + + rowNum := 1 + for { + select { + case <-ctx.Done(): + return &MetricsError{ + Type: ErrProcessingTimeout, + Message: "CSV processing cancelled", + Cause: ctx.Err(), + } + default: + } + + record, err := csvReader.Read() + if err != nil { + if err.Error() == "EOF" { + break + } + return &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to parse CSV row", + Cause: err, + } + } + + if err := processor(record); err != nil { + return &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to process CSV row", + Cause: err, + } + } + + rowNum++ + } + + return nil +} + +// BatchStream processes CSV rows in batches for better performance +func (r *CSVReader) BatchStream(ctx context.Context, batchSize int, processor func([][]string) error) error { + var batch [][]string + + err := r.StreamRows(ctx, func(row []string) error { + batch = append(batch, row) + + if len(batch) >= batchSize { + if err := processor(batch); err != nil { + return err + } + batch = batch[:0] // Reset batch + } + + return nil + }) + + // Process remaining rows in batch + if err == nil && len(batch) > 0 { + err = processor(batch) + } + + return err +} diff --git a/internal/handlers/data/csv_test.go b/internal/handlers/data/csv_test.go new file mode 100644 index 0000000..75b36a5 --- /dev/null +++ b/internal/handlers/data/csv_test.go @@ -0,0 +1,150 @@ +package data + +import ( + "context" + "os" + "reflect" + "testing" +) + +func TestNewCSVReader(t *testing.T) { + reader := NewCSVReader("test.csv") + if reader.filepath != "test.csv" { + t.Errorf("Expected filepath 'test.csv', got '%s'", reader.filepath) + } +} + +func TestCSVReader_ReadAll(t *testing.T) { + // Create a temporary CSV file for testing + tmpFile, err := os.CreateTemp("", "test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write test data + testData := `active,branch,cwd,file,filetype,project,time +true,main,/home/user,test.go,go,myproject,2024-01-01 10:00:00 +false,main,/home/user,test.go,go,myproject,2024-01-01 10:01:00 +true,feature,/home/user,main.go,go,myproject,2024-01-01 10:02:00` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + // Test ReadAll + reader := NewCSVReader(tmpFile.Name()) + data, err := reader.ReadAll() + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + + expectedRows := 4 // header + 3 data rows + if len(data) != expectedRows { + t.Errorf("Expected %d rows, got %d", expectedRows, len(data)) + } + + // Check header + expectedHeader := []string{"active", "branch", "cwd", "file", "filetype", "project", "time"} + if !reflect.DeepEqual(data[0], expectedHeader) { + t.Errorf("Expected header %v, got %v", expectedHeader, data[0]) + } + + // Check first data row + expectedFirstRow := []string{"true", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:00:00"} + if !reflect.DeepEqual(data[1], expectedFirstRow) { + t.Errorf("Expected first row %v, got %v", expectedFirstRow, data[1]) + } +} + +func TestCSVReader_StreamRows(t *testing.T) { + // Create a temporary CSV file + tmpFile, err := os.CreateTemp("", "test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + testData := `active,branch,cwd +true,main,/home +false,dev,/tmp` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + // Test streaming + reader := NewCSVReader(tmpFile.Name()) + var rows [][]string + + ctx := context.Background() + err = reader.StreamRows(ctx, func(row []string) error { + rows = append(rows, row) + return nil + }) + + if err != nil { + t.Fatalf("StreamRows failed: %v", err) + } + + if len(rows) != 3 { // header + 2 data rows + t.Errorf("Expected 3 rows, got %d", len(rows)) + } +} + +func TestCSVReader_FileNotFound(t *testing.T) { + reader := NewCSVReader("nonexistent.csv") + _, err := reader.ReadAll() + + if err == nil { + t.Error("Expected error for nonexistent file") + } + + if metricsErr, ok := err.(*MetricsError); ok { + if metricsErr.Type != ErrFileNotFound { + t.Errorf("Expected ErrFileNotFound, got %v", metricsErr.Type) + } + } else { + t.Error("Expected MetricsError type") + } +} + +func TestCSVReader_ContextCancellation(t *testing.T) { + // Create a temporary CSV file + tmpFile, err := os.CreateTemp("", "test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write some test data + testData := `active,branch +true,main +false,dev` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + // Test with cancelled context + reader := NewCSVReader(tmpFile.Name()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err = reader.StreamRows(ctx, func(row []string) error { + return nil + }) + + if err == nil { + t.Error("Expected error for cancelled context") + } + + if metricsErr, ok := err.(*MetricsError); ok { + if metricsErr.Type != ErrProcessingTimeout { + t.Errorf("Expected ErrProcessingTimeout, got %v", metricsErr.Type) + } + } +} diff --git a/internal/handlers/data/types.go b/internal/handlers/data/types.go new file mode 100644 index 0000000..5f0fb85 --- /dev/null +++ b/internal/handlers/data/types.go @@ -0,0 +1,79 @@ +package data + +import ( + "time" +) + +// PendulumMetric represents aggregated metrics for a specific column/field +type PendulumMetric struct { + Name string + Index int + Value map[string]*PendulumEntry +} + +// PendulumEntry represents aggregated data for a specific value within a metric +type PendulumEntry struct { + ID string + ActiveCount uint + TotalCount uint + ActiveTime time.Duration + TotalTime time.Duration + ActiveTimestamps []string + Timestamps []string + ActivePct float64 +} + +// MetricsParams holds parameters for metrics generation +type MetricsParams struct { + LogFile string + TopN int + TimeRange string + ReportExcludes map[string][]string + ReportSectionExcludes []string + TimeFormat string + TimeZone string + TimeoutLen float64 +} + +// CSVColumns maps column names to their indices in the CSV +var CSVColumns = map[string]int{ + "active": 0, + "branch": 1, + "directory": 2, + "file": 3, + "filetype": 4, + "project": 5, + "time": 6, +} + +// MetricsError represents errors that can occur during metrics processing +type MetricsError struct { + Type ErrorType + Message string + Cause error +} + +func (e *MetricsError) Error() string { + if e.Cause != nil { + return e.Message + ": " + e.Cause.Error() + } + return e.Message +} + +// ErrorType defines the category of metrics processing errors +type ErrorType int + +const ( + ErrInvalidData ErrorType = iota + ErrFileNotFound + ErrParsingFailed + ErrProcessingTimeout + ErrInvalidParameters +) + +// ProcessingResult holds the results of metrics processing +type ProcessingResult struct { + Metrics []PendulumMetric + Processed int + Duration time.Duration +} diff --git a/internal/handlers/data/utils.go b/internal/handlers/data/utils.go new file mode 100644 index 0000000..ff4704e --- /dev/null +++ b/internal/handlers/data/utils.go @@ -0,0 +1,167 @@ +package data + +import ( + "fmt" + "regexp" + "time" +) + +// timeDiff calculates the time difference between the last two timestamps +func TimeDiff(timestamps []string, timeoutLen float64, clamp bool) (time.Duration, error) { + n := len(timestamps) + if n < 2 { + return time.Duration(0), nil + } + + curr, prev := timestamps[n-1], timestamps[n-2] + var d time.Duration + var err error + + if !clamp { + d, err = calcDuration(curr, prev) + } else { + d, err = calcDurationWithinHour(curr, prev) + } + + if err != nil { + return time.Duration(0), &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to calculate time difference", + Cause: err, + } + } + + // If difference exceeds timeout, editor was closed between sessions + if d.Seconds() > timeoutLen { + return time.Duration(0), nil + } + + return d, nil +} + +// calcDuration calculates the duration between two string timestamps (curr - prev) +func calcDuration(curr string, prev string) (time.Duration, error) { + layout := "2006-01-02 15:04:05" + + currT, err := time.Parse(layout, curr) + if err != nil { + return time.Duration(0), fmt.Errorf("failed to parse current timestamp %s: %w", curr, err) + } + + prevT, err := time.Parse(layout, prev) + if err != nil { + return time.Duration(0), fmt.Errorf("failed to parse previous timestamp %s: %w", prev, err) + } + + return currT.Sub(prevT), nil +} + +// calcDurationWithinHour calculates duration clamped to the hour boundary +func calcDurationWithinHour(curr string, prev string) (time.Duration, error) { + layout := "2006-01-02 15:04:05" + + currT, err := time.Parse(layout, curr) + if err != nil { + return 0, fmt.Errorf("failed to parse current timestamp %s: %w", curr, err) + } + + prevT, err := time.Parse(layout, prev) + if err != nil { + return 0, fmt.Errorf("failed to parse previous timestamp %s: %w", prev, err) + } + + // If prev_t is within the same hour as curr_t, return the direct difference + if prevT.Hour() == currT.Hour() && prevT.Day() == currT.Day() { + return currT.Sub(prevT), nil + } + + // Otherwise, clamp prev_t to the start of curr_t's hour + clampedPrevT := time.Date(currT.Year(), currT.Month(), currT.Day(), currT.Hour(), + 0, 0, 0, currT.Location()) + + return currT.Sub(clampedPrevT), nil +} + +// CompileRegexPatterns compiles regex patterns for exclusion filtering +func CompileRegexPatterns(filters []string) ([]*regexp.Regexp, error) { + if len(filters) == 0 { + return nil, nil + } + + patterns := make([]*regexp.Regexp, 0, len(filters)) + for _, expr := range filters { + r, err := regexp.Compile(expr) + if err != nil { + return nil, &MetricsError{ + Type: ErrInvalidParameters, + Message: fmt.Sprintf("failed to compile regex pattern: %s", expr), + Cause: err, + } + } + patterns = append(patterns, r) + } + + return patterns, nil +} + +// IsExcluded checks if a value matches any exclusion pattern +func IsExcluded(val string, patterns []*regexp.Regexp) bool { + for _, r := range patterns { + if r.MatchString(val) { + return true + } + } + return false +} + +// IsTimestampInRange checks if a timestamp falls within the specified time range +func IsTimestampInRange(timestampStr, rangeType, timeZone string) (bool, error) { + layout := "2006-01-02 15:04:05" + + timestamp, err := time.Parse(layout, timestampStr) + if err != nil { + return false, &MetricsError{ + Type: ErrParsingFailed, + Message: fmt.Sprintf("failed to parse timestamp: %s", timestampStr), + Cause: err, + } + } + + // Load timezone + loc, err := time.LoadLocation(timeZone) + if err != nil { + loc = time.UTC // Fallback to UTC + } else { + timestamp = timestamp.In(loc) + } + + now := time.Now().In(loc) + var startOfRange, endOfRange time.Time + + switch rangeType { + case "today", "day": + startOfRange = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + endOfRange = startOfRange.Add(24 * time.Hour).Add(-time.Nanosecond) + case "year": + startOfRange = time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc) + endOfRange = startOfRange.AddDate(1, 0, 0).Add(-time.Nanosecond) + case "month": + startOfRange = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) + endOfRange = startOfRange.AddDate(0, 1, 0).Add(-time.Nanosecond) + case "week": + startOfRange = now.AddDate(0, 0, -6) + endOfRange = now.Add(24*time.Hour - time.Nanosecond) + case "hour": + startOfRange = now.Truncate(time.Hour) + endOfRange = startOfRange.Add(time.Hour).Add(-time.Nanosecond) + case "all": + return true, nil + default: + return false, &MetricsError{ + Type: ErrInvalidParameters, + Message: fmt.Sprintf("unsupported time range: %s", rangeType), + } + } + + return timestamp.After(startOfRange) && timestamp.Before(endOfRange), nil +} diff --git a/internal/handlers/data/utils_test.go b/internal/handlers/data/utils_test.go new file mode 100644 index 0000000..0ca913b --- /dev/null +++ b/internal/handlers/data/utils_test.go @@ -0,0 +1,227 @@ +package data + +import ( + "testing" + "time" +) + +func TestTimeDiff(t *testing.T) { + tests := []struct { + name string + timestamps []string + timeoutLen float64 + clamp bool + expected time.Duration + expectError bool + }{ + { + name: "empty timestamps", + timestamps: []string{}, + timeoutLen: 180.0, + clamp: false, + expected: 0, + }, + { + name: "single timestamp", + timestamps: []string{"2024-01-01 10:00:00"}, + timeoutLen: 180.0, + clamp: false, + expected: 0, + }, + { + name: "normal difference", + timestamps: []string{"2024-01-01 10:00:00", "2024-01-01 10:01:00"}, + timeoutLen: 180.0, + clamp: false, + expected: time.Minute, + }, + { + name: "exceeds timeout", + timestamps: []string{"2024-01-01 10:00:00", "2024-01-01 12:00:00"}, + timeoutLen: 180.0, + clamp: false, + expected: 0, // Should return 0 because it exceeds timeout + }, + { + name: "invalid timestamp format", + timestamps: []string{"invalid", "2024-01-01 10:01:00"}, + timeoutLen: 180.0, + clamp: false, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := TimeDiff(tt.timestamps, tt.timeoutLen, tt.clamp) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestCompileRegexPatterns(t *testing.T) { + tests := []struct { + name string + filters []string + expectError bool + expectCount int + }{ + { + name: "empty filters", + filters: []string{}, + expectError: false, + expectCount: 0, + }, + { + name: "valid patterns", + filters: []string{"test.*", "^main$"}, + expectError: false, + expectCount: 2, + }, + { + name: "invalid pattern", + filters: []string{"[invalid"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patterns, err := CompileRegexPatterns(tt.filters) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(patterns) != tt.expectCount { + t.Errorf("Expected %d patterns, got %d", tt.expectCount, len(patterns)) + } + }) + } +} + +func TestIsExcluded(t *testing.T) { + patterns, err := CompileRegexPatterns([]string{"test.*", "^main$"}) + if err != nil { + t.Fatalf("Failed to compile patterns: %v", err) + } + + tests := []struct { + name string + value string + expected bool + }{ + {"matches test pattern", "test123", true}, + {"matches main pattern", "main", true}, + {"no match", "other", false}, + {"partial main match", "mainline", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsExcluded(tt.value, patterns) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestIsTimestampInRange(t *testing.T) { + tests := []struct { + name string + timestamp string + rangeType string + timeZone string + expectError bool + // Note: We can't easily test the actual range logic without mocking time.Now() + // so we focus on error conditions and "all" range + }{ + { + name: "all range always true", + timestamp: "2024-01-01 10:00:00", + rangeType: "all", + timeZone: "UTC", + }, + { + name: "invalid timestamp", + timestamp: "invalid", + rangeType: "day", + timeZone: "UTC", + expectError: true, + }, + { + name: "invalid range type", + timestamp: "2024-01-01 10:00:00", + rangeType: "invalid", + timeZone: "UTC", + expectError: true, + }, + { + name: "valid day range", + timestamp: "2024-01-01 10:00:00", + rangeType: "day", + timeZone: "UTC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := IsTimestampInRange(tt.timestamp, tt.rangeType, tt.timeZone) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if tt.rangeType == "all" && !result { + t.Error("Expected 'all' range to return true") + } + }) + } +} + +func BenchmarkTimeDiff(b *testing.B) { + timestamps := []string{"2024-01-01 10:00:00", "2024-01-01 10:01:00"} + + b.ResetTimer() + for b.Loop() { + _, _ = TimeDiff(timestamps, 180.0, false) + } +} + +func BenchmarkCompileRegexPatterns(b *testing.B) { + patterns := []string{"test.*", "^main$", ".*\\.go$"} + + b.ResetTimer() + for b.Loop() { + _, _ = CompileRegexPatterns(patterns) + } +} diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go index 5d909e9..417a4a7 100644 --- a/internal/handlers/metrics.go +++ b/internal/handlers/metrics.go @@ -1,24 +1,108 @@ package handlers import ( + "context" + "fmt" "log" + "strings" + "time" + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/ptdewey/pendulum-server/internal/handlers/data" + "github.com/ptdewey/pendulum-server/internal/handlers/params" + "github.com/ptdewey/pendulum-server/internal/handlers/prettify" "github.com/tliron/glsp" ) -// TODO: rename to "summary" report +// GenerateMetricsReport generates a formatted metrics report via LSP func GenerateMetricsReport(ctx *glsp.Context, args []any) (string, error) { - log.Printf("executing command 'pendulum.generateMetricsReport' with args %v (%T)", args, args) - // TODO: - return "", nil + log.Printf("executing command 'pendulum.generateMetricsReport' with args %v", args) + + // Parse and validate parameters + metricsParams, err := params.ParseMetricsParams(args) + if err != nil { + log.Printf("Error parsing metrics parameters: %v", err) + return "", err + } + + // Use config log file if not provided in params + if metricsParams.LogFile == "" { + cfg := config.Config() + if cfg != nil && cfg.LogFile != "" { + metricsParams.LogFile = cfg.LogFile + } else { + return "", &data.MetricsError{ + Type: data.ErrFileNotFound, + Message: "no log file specified and no default configured", + } + } + } + + // Create processing context with timeout + processingCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Read CSV data + csvReader := data.NewCSVReader(metricsParams.LogFile) + csvData, err := csvReader.ReadAll() + if err != nil { + log.Printf("Error reading CSV file: %v", err) + return "", err + } + + if len(csvData) <= 1 { + return "# No data available\n\nThe log file contains no activity data.", nil + } + + // Create aggregator and process metrics + aggregator := data.NewMetricsAggregator(metricsParams) + result, err := aggregator.AggregatePendulumMetrics(processingCtx, csvData) + if err != nil { + log.Printf("Error aggregating metrics: %v", err) + return "", err + } + + // Format results + formatter := prettify.NewMetricsFormatter(metricsParams) + formattedLines := formatter.FormatMetrics(result.Metrics) + + // Add processing metadata + metadata := []string{ + "", + "---", + "", + "**Processing Summary:**", + "- Rows processed: " + formatNumber(result.Processed), + "- Processing time: " + result.Duration.String(), + "- Metrics generated: " + formatNumber(len(result.Metrics)), + } + + formattedLines = append(formattedLines, metadata...) + + // Join all lines into a single string + output := strings.Join(formattedLines, "\n") + + log.Printf("Generated metrics report: %d lines, %d metrics, processed in %v", + len(formattedLines), len(result.Metrics), result.Duration) + + return output, nil } -// TODO: find a better name for this one +// GenerateHourlyReport generates an hourly activity report (placeholder for future implementation) func GenerateHourlyReport(ctx *glsp.Context, args []any) (string, error) { - log.Printf("executing command 'pendulum.generateHourlyReport' with args %v (%T)", args, args) + log.Printf("executing command 'pendulum.generateHourlyReport' with args %v", args) - // ctx.Notify() + // TODO: Implement hourly report generation + return "# Hourly Report\n\n*Coming soon...*", nil +} - // TODO: - return "", nil +// formatNumber formats a number for display with commas +func formatNumber(n int) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + if n < 1000000 { + return fmt.Sprintf("%d,%03d", n/1000, n%1000) + } + return fmt.Sprintf("%.1fM", float64(n)/1000000) } diff --git a/internal/handlers/metrics_test.go b/internal/handlers/metrics_test.go new file mode 100644 index 0000000..6090840 --- /dev/null +++ b/internal/handlers/metrics_test.go @@ -0,0 +1,286 @@ +package handlers + +import ( + "os" + "strings" + "testing" + + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/tliron/glsp" +) + +// TestGenerateMetricsReport_Integration tests the complete metrics generation flow +func TestGenerateMetricsReport_Integration(t *testing.T) { + // Create a temporary CSV file with test data + tmpFile, err := os.CreateTemp("", "pendulum_test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write comprehensive test data + testData := `active,branch,directory,file,filetype,project,time +true,main,/home/user/project,main.go,go,myproject,2024-01-01 10:00:00 +false,main,/home/user/project,main.go,go,myproject,2024-01-01 10:01:00 +true,main,/home/user/project,test.go,go,myproject,2024-01-01 10:02:00 +true,feature,/home/user/project,api.go,go,myproject,2024-01-01 10:03:00 +false,feature,/home/user/docs,README.md,markdown,myproject,2024-01-01 10:04:00 +true,main,/home/user/project,utils.go,go,myproject,2024-01-01 10:05:00` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + tests := []struct { + name string + args []any + expectError bool + validateFunc func(string) error + }{ + { + name: "basic metrics report", + args: []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": 3, + }, + }, + expectError: false, + validateFunc: func(result string) error { + // Check that we got a proper markdown report + if !strings.Contains(result, "# Pendulum Metrics Report") { + t.Error("Expected report header") + } + if !strings.Contains(result, "## Top") { + t.Error("Expected metrics sections") + } + if !strings.Contains(result, "Processing Summary") { + t.Error("Expected processing summary") + } + return nil + }, + }, + { + name: "metrics with exclusions", + args: []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": 2, + "report_excludes": map[string]any{ + "file": []any{"test.*"}, + }, + "report_section_excludes": []any{"branch"}, + }, + }, + expectError: false, + validateFunc: func(result string) error { + // Should not contain branch metrics + if strings.Contains(result, "Branches") { + t.Error("Expected branch section to be excluded") + } + // Should not contain test.go (excluded by pattern) + if strings.Contains(result, "test.go") { + t.Error("Expected test.go to be excluded") + } + return nil + }, + }, + { + name: "invalid log file", + args: []any{ + map[string]any{ + "log_file": "/nonexistent/file.csv", + }, + }, + expectError: true, + }, + { + name: "invalid parameters", + args: []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": -1, // Invalid + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock LSP context (we don't use it in our implementation) + ctx := (*glsp.Context)(nil) + + result, err := GenerateMetricsReport(ctx, tt.args) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if tt.validateFunc != nil { + if err := tt.validateFunc(result); err != nil { + t.Error(err) + } + } + }) + } +} + +func TestGenerateMetricsReport_WithConfig(t *testing.T) { + // Test using config default log file + tmpFile, err := os.CreateTemp("", "pendulum_config_test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write minimal test data + testData := `active,branch,directory,file,filetype,project,time +true,main,/home,test.go,go,project,2024-01-01 10:00:00` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + // Set up config with the test file + err = config.Setup( + config.WithActivityFile(tmpFile.Name()), + config.WithLogFile("/tmp/lsp.log"), + config.WithDebug(false), + ) + if err != nil { + t.Fatalf("Failed to setup config: %v", err) + } + + // Test with empty log_file (should use config default) + args := []any{ + map[string]any{ + "top_n": 5, + }, + } + + ctx := (*glsp.Context)(nil) + result, err := GenerateMetricsReport(ctx, args) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !strings.Contains(result, "# Pendulum Metrics Report") { + t.Error("Expected valid report when using config default") + } +} + +func TestGenerateMetricsReport_EmptyFile(t *testing.T) { + // Test with empty CSV file + tmpFile, err := os.CreateTemp("", "pendulum_empty*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write only header + testData := `active,branch,directory,file,filetype,project,time` + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + args := []any{ + map[string]any{ + "log_file": tmpFile.Name(), + }, + } + + ctx := (*glsp.Context)(nil) + result, err := GenerateMetricsReport(ctx, args) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !strings.Contains(result, "No data available") { + t.Error("Expected 'No data available' message for empty file") + } +} + +func TestGenerateHourlyReport(t *testing.T) { + // Currently just a placeholder - test that it returns without error + ctx := (*glsp.Context)(nil) + args := []any{ + map[string]any{ + "log_file": "/test.csv", + }, + } + + result, err := GenerateHourlyReport(ctx, args) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !strings.Contains(result, "Coming soon") { + t.Error("Expected placeholder message for hourly report") + } +} + +func BenchmarkGenerateMetricsReport(b *testing.B) { + // Create a larger test file for benchmarking + tmpFile, err := os.CreateTemp("", "pendulum_bench*.csv") + if err != nil { + b.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Generate more test data + var lines []string + lines = append(lines, "active,branch,directory,file,filetype,project,time") + + for i := 0; i < 1000; i++ { + active := "true" + if i%2 == 0 { + active = "false" + } + line := strings.Join([]string{ + active, + "main", + "/home/user/project", + "file.go", + "go", + "myproject", + "2024-01-01 10:00:00", + }, ",") + lines = append(lines, line) + } + + if _, err := tmpFile.WriteString(strings.Join(lines, "\n")); err != nil { + b.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + args := []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": 10, + }, + } + + ctx := (*glsp.Context)(nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := GenerateMetricsReport(ctx, args) + if err != nil { + b.Fatalf("Generate metrics failed: %v", err) + } + } +} diff --git a/internal/handlers/params/parser.go b/internal/handlers/params/parser.go new file mode 100644 index 0000000..557255b --- /dev/null +++ b/internal/handlers/params/parser.go @@ -0,0 +1,159 @@ +package params + +import ( + "fmt" + + "github.com/ptdewey/pendulum-server/internal/handlers/data" +) + +// ParseMetricsParams parses and validates LSP command arguments for metrics generation +func ParseMetricsParams(args []any) (*data.MetricsParams, error) { + if len(args) == 0 { + return nil, &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "no arguments provided for metrics command", + } + } + + // First argument should be a map containing the options + argMap, ok := args[0].(map[string]any) + if !ok { + return nil, &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "expected first argument to be an options map", + } + } + + params := &data.MetricsParams{ + TopN: 5, // defaults + TimeRange: "all", + TimeFormat: "12h", + TimeZone: "UTC", + TimeoutLen: 180.0, + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: make([]string, 0), + } + + // Parse log_file (optional - can use config default) + if logFile, ok := argMap["log_file"].(string); ok { + params.LogFile = logFile + } + + // Parse top_n (optional) + if topN, ok := argMap["top_n"]; ok { + switch v := topN.(type) { + case int: + params.TopN = v + case int64: + params.TopN = int(v) + case float64: + params.TopN = int(v) + default: + return nil, &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "top_n must be a number", + } + } + } + + // Parse time_range (optional) + if timeRange, ok := argMap["time_range"].(string); ok { + params.TimeRange = timeRange + } + + // Parse timeout_len (optional) + if timeoutLen, ok := argMap["timeout_len"]; ok { + switch v := timeoutLen.(type) { + case int: + params.TimeoutLen = float64(v) + case int64: + params.TimeoutLen = float64(v) + case float64: + params.TimeoutLen = v + default: + return nil, &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "timeout_len must be a number", + } + } + } + + // Parse time_format (optional) + if timeFormat, ok := argMap["time_format"].(string); ok { + params.TimeFormat = timeFormat + } + + // Parse time_zone (optional) + if timeZone, ok := argMap["time_zone"].(string); ok { + params.TimeZone = timeZone + } + + // Parse report_excludes (optional) + if reportExcludes, ok := argMap["report_excludes"].(map[string]any); ok { + for key, value := range reportExcludes { + if excludeList, ok := value.([]any); ok { + stringList := make([]string, 0, len(excludeList)) + for _, item := range excludeList { + if str, ok := item.(string); ok { + stringList = append(stringList, str) + } + } + params.ReportExcludes[key] = stringList + } + } + } + + // Parse report_section_excludes (optional) + if sectionExcludes, ok := argMap["report_section_excludes"].([]any); ok { + for _, item := range sectionExcludes { + if str, ok := item.(string); ok { + params.ReportSectionExcludes = append(params.ReportSectionExcludes, str) + } + } + } + + // Validate parameters + if err := validateParams(params); err != nil { + return nil, err + } + + return params, nil +} + +// validateParams validates the parsed parameters +func validateParams(params *data.MetricsParams) error { + // Don't validate LogFile here - it can be empty and filled from config later + + if params.TopN < 1 { + return &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "top_n must be greater than 0", + } + } + + if params.TimeoutLen < 0 { + return &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "timeout_len must be non-negative", + } + } + + validTimeRanges := map[string]bool{ + "all": true, + "today": true, + "day": true, + "week": true, + "month": true, + "year": true, + "hour": true, + } + + if !validTimeRanges[params.TimeRange] { + return &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: fmt.Sprintf("invalid time_range: %s", params.TimeRange), + } + } + + return nil +} diff --git a/internal/handlers/params/parser_test.go b/internal/handlers/params/parser_test.go new file mode 100644 index 0000000..6908da3 --- /dev/null +++ b/internal/handlers/params/parser_test.go @@ -0,0 +1,265 @@ +package params + +import ( + "testing" + + "github.com/ptdewey/pendulum-server/internal/handlers/data" +) + +func TestParseMetricsParams(t *testing.T) { + tests := []struct { + name string + args []any + expectError bool + validate func(*data.MetricsParams) error + }{ + { + name: "empty args", + args: []any{}, + expectError: true, + }, + { + name: "invalid arg type", + args: []any{"not a map"}, + expectError: true, + }, + { + name: "missing log_file is ok", + args: []any{ + map[string]any{ + "top_n": 5, + }, + }, + expectError: false, + validate: func(p *data.MetricsParams) error { + if p.LogFile != "" { + t.Errorf("Expected empty log_file, got '%s'", p.LogFile) + } + return nil + }, + }, + { + name: "minimal valid args", + args: []any{ + map[string]any{ + "log_file": "/path/to/log.csv", + }, + }, + expectError: false, + validate: func(p *data.MetricsParams) error { + if p.LogFile != "/path/to/log.csv" { + t.Errorf("Expected log_file '/path/to/log.csv', got '%s'", p.LogFile) + } + if p.TopN != 5 { // default + t.Errorf("Expected default top_n 5, got %d", p.TopN) + } + if p.TimeRange != "all" { // default + t.Errorf("Expected default time_range 'all', got '%s'", p.TimeRange) + } + return nil + }, + }, + { + name: "full valid args", + args: []any{ + map[string]any{ + "log_file": "/path/to/log.csv", + "top_n": 10, + "time_range": "day", + "timeout_len": 120.0, + "time_format": "24h", + "time_zone": "America/New_York", + "report_excludes": map[string]any{ + "file": []any{"test.*", ".*\\.tmp"}, + }, + "report_section_excludes": []any{"branch", "project"}, + }, + }, + expectError: false, + validate: func(p *data.MetricsParams) error { + if p.LogFile != "/path/to/log.csv" { + t.Errorf("Expected log_file '/path/to/log.csv', got '%s'", p.LogFile) + } + if p.TopN != 10 { + t.Errorf("Expected top_n 10, got %d", p.TopN) + } + if p.TimeRange != "day" { + t.Errorf("Expected time_range 'day', got '%s'", p.TimeRange) + } + if p.TimeoutLen != 120.0 { + t.Errorf("Expected timeout_len 120.0, got %f", p.TimeoutLen) + } + if p.TimeFormat != "24h" { + t.Errorf("Expected time_format '24h', got '%s'", p.TimeFormat) + } + if p.TimeZone != "America/New_York" { + t.Errorf("Expected time_zone 'America/New_York', got '%s'", p.TimeZone) + } + + // Check report excludes + fileExcludes := p.ReportExcludes["file"] + if len(fileExcludes) != 2 { + t.Errorf("Expected 2 file excludes, got %d", len(fileExcludes)) + } + + // Check section excludes + if len(p.ReportSectionExcludes) != 2 { + t.Errorf("Expected 2 section excludes, got %d", len(p.ReportSectionExcludes)) + } + + return nil + }, + }, + { + name: "invalid top_n type", + args: []any{ + map[string]any{ + "log_file": "/path/to/log.csv", + "top_n": "not a number", + }, + }, + expectError: true, + }, + { + name: "invalid timeout_len type", + args: []any{ + map[string]any{ + "log_file": "/path/to/log.csv", + "timeout_len": "not a number", + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params, err := ParseMetricsParams(tt.args) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if tt.validate != nil { + if err := tt.validate(params); err != nil { + t.Error(err) + } + } + }) + } +} + +func TestValidateParams(t *testing.T) { + tests := []struct { + name string + params *data.MetricsParams + expectError bool + }{ + { + name: "valid params", + params: &data.MetricsParams{ + LogFile: "/path/to/log.csv", + TopN: 5, + TimeRange: "all", + TimeoutLen: 180.0, + }, + expectError: false, + }, + { + name: "empty log file is ok", + params: &data.MetricsParams{ + LogFile: "", + TopN: 5, + TimeRange: "all", + TimeoutLen: 180.0, + }, + expectError: false, + }, + { + name: "invalid top_n", + params: &data.MetricsParams{ + LogFile: "/path/to/log.csv", + TopN: 0, + TimeRange: "all", + TimeoutLen: 180.0, + }, + expectError: true, + }, + { + name: "negative timeout", + params: &data.MetricsParams{ + LogFile: "/path/to/log.csv", + TopN: 5, + TimeRange: "all", + TimeoutLen: -1.0, + }, + expectError: true, + }, + { + name: "invalid time range", + params: &data.MetricsParams{ + LogFile: "/path/to/log.csv", + TopN: 5, + TimeRange: "invalid", + TimeoutLen: 180.0, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateParams(tt.params) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestParseMetricsParams_NumberTypes(t *testing.T) { + // Test different number types that might come from JSON/LSP + tests := []struct { + name string + value any + expected int + }{ + {"int", 10, 10}, + {"int64", int64(15), 15}, + {"float64", 20.0, 20}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := []any{ + map[string]any{ + "log_file": "/test.csv", + "top_n": tt.value, + }, + } + + params, err := ParseMetricsParams(args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if params.TopN != tt.expected { + t.Errorf("Expected top_n %d, got %d", tt.expected, params.TopN) + } + }) + } +} diff --git a/internal/handlers/prettify/formatter.go b/internal/handlers/prettify/formatter.go new file mode 100644 index 0000000..d194bbe --- /dev/null +++ b/internal/handlers/prettify/formatter.go @@ -0,0 +1,220 @@ +package prettify + +import ( + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/ptdewey/pendulum-server/internal/handlers/data" +) + +// MetricsFormatter handles formatting of metrics data for display +type MetricsFormatter struct { + params *data.MetricsParams +} + +// NewMetricsFormatter creates a new metrics formatter +func NewMetricsFormatter(params *data.MetricsParams) *MetricsFormatter { + return &MetricsFormatter{params: params} +} + +// FormatMetrics converts a slice of PendulumMetric structs into formatted strings +func (f *MetricsFormatter) FormatMetrics(metrics []data.PendulumMetric) []string { + var lines []string + + // Add header with metadata + header := f.generateHeader(len(metrics)) + if header != "" { + lines = append(lines, header) + } + + // Format each metric + for i, metric := range metrics { + if metric.Name != "" && len(metric.Value) != 0 { + formatted := f.formatMetric(metric, f.params.TopN) + lines = append(lines, formatted) + + // Add empty line between metrics (but not after the last one) + if i < len(metrics)-1 { + lines = append(lines, "") + } + } + } + + return lines +} + +// generateHeader creates a header with report metadata +func (f *MetricsFormatter) generateHeader(metricCount int) string { + var parts []string + + parts = append(parts, "# Pendulum Metrics Report") + parts = append(parts, fmt.Sprintf("**Generated:** %s", time.Now().Format("2006-01-02 15:04:05"))) + parts = append(parts, fmt.Sprintf("**Time Range:** %s", f.params.TimeRange)) + parts = append(parts, fmt.Sprintf("**Log File:** %s", truncateHome(f.params.LogFile))) + parts = append(parts, fmt.Sprintf("**Metrics:** %d", metricCount)) + parts = append(parts, "") + + return strings.Join(parts, "\n\n") +} + +// formatMetric converts a single PendulumMetric struct into a formatted string +func (f *MetricsFormatter) formatMetric(metric data.PendulumMetric, n int) string { + keys := make([]string, 0, len(metric.Value)) + for k := range metric.Value { + keys = append(keys, k) + } + + // Sort by active time (descending) + sort.SliceStable(keys, func(a, b int) bool { + return metric.Value[keys[a]].ActiveTime > metric.Value[keys[b]].ActiveTime + }) + + n = min(n, len(keys)) + + // Find longest ID for alignment + maxIDLen := 15 + for i := 0; i < n; i++ { + idLen := len(f.truncatePath(metric.Value[keys[i]].ID)) + maxIDLen = max(maxIDLen, idLen) + } + + // Generate formatted output + name := f.titleCase(metric.Name) + out := fmt.Sprintf("## Top %d %s\n", n, f.prettifyMetricName(name)) + + for i := 0; i < n; i++ { + entry := metric.Value[keys[i]] + if math.IsNaN(entry.ActivePct) { + continue + } + out += f.formatEntry(entry, i+1, maxIDLen, n) + "\n" + } + + return out +} + +// formatEntry converts a single PendulumEntry into a formatted string +func (f *MetricsFormatter) formatEntry(e *data.PendulumEntry, rank int, maxIDLen int, totalRanks int) string { + rankWidth := len(fmt.Sprintf("%d", totalRanks)) + format := fmt.Sprintf("%%%dd. %%-%ds: Total %%+6s, Active %%+6s (%%-5.2f%%%%)", + rankWidth, maxIDLen+1) + + return fmt.Sprintf(format, + rank, + f.truncatePath(e.ID), + f.formatDuration(e.TotalTime), + f.formatDuration(e.ActiveTime), + e.ActivePct*100) +} + +// prettifyMetricName converts metric names into a more readable form +func (f *MetricsFormatter) prettifyMetricName(name string) string { + switch name { + case "Cwd", "Directory": + return "Directories" + case "Branch": + return "Branches" + case "File": + return "Files" + case "Filetype": + return "File Types" + case "Project": + return "Projects" + default: + return fmt.Sprintf("%ss", name) + } +} + +// truncatePath truncates long file paths for better display +func (f *MetricsFormatter) truncatePath(path string) string { + maxLen := 50 + + path = truncateHome(path) + + if len(path) <= maxLen { + return path + } + + // For file paths, show the end part + if strings.Contains(path, string(filepath.Separator)) { + parts := strings.FieldsFunc(path, func(c rune) bool { + return c == filepath.Separator + }) + + if len(parts) > 1 { + // Try to show last few parts + result := parts[len(parts)-1] + for i := len(parts) - 2; i >= 0 && len(result) < maxLen-3; i-- { + candidate := parts[i] + string(filepath.Separator) + result + if len(candidate) <= maxLen-3 { + result = candidate + } else { + break + } + } + if len(result) < len(path) { + return "..." + result + } + } + } + + // Fallback: truncate from the start + return "..." + path[len(path)-maxLen+3:] +} + +// formatDuration formats a time duration for display +func (f *MetricsFormatter) formatDuration(d time.Duration) string { + if d == 0 { + return "0s" + } + + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if hours > 0 { + if f.params.TimeFormat == "24h" || hours >= 24 { + return fmt.Sprintf("%dh%02dm", hours, minutes) + } + // 12h format + if hours > 12 { + return fmt.Sprintf("%dh%02dm", hours-12, minutes) + } + return fmt.Sprintf("%dh%02dm", hours, minutes) + } + + if minutes > 0 { + return fmt.Sprintf("%dm%02ds", minutes, seconds) + } + + return fmt.Sprintf("%ds", seconds) +} + +// titleCase converts a string to title case +func (f *MetricsFormatter) titleCase(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +func truncateHome(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + + if strings.HasPrefix(path, home) { + rpath, err := filepath.Rel(home, path) + if err == nil { + path = "~" + string(filepath.Separator) + rpath + } + } + + return path +} diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index 4fea887..8b8133b 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -10,28 +10,11 @@ local function create_buffer(content, filetype) -- Set buffer options vim.api.nvim_buf_set_option(buf, "filetype", filetype or "markdown") - -- Process content based on type + -- Process content - LSP always returns a string local lines = {} - - if type(content) == "table" then - -- If content is an array - for _, section in ipairs(content) do - if type(section) == "string" then - -- If it's a string, split it on newlines and add each line - for line in section:gmatch("[^\r\n]+") do - table.insert(lines, line) - end - elseif type(section) == "table" then - -- If it's an array of strings - for _, line in ipairs(section) do - if type(line) == "string" then - table.insert(lines, line) - end - end - end - end - elseif type(content) == "string" then - -- For a single string + + if type(content) == "string" then + -- Split string on newlines and add each line for line in content:gmatch("[^\r\n]+") do table.insert(lines, line) end @@ -113,7 +96,7 @@ local function setup_pendulum_commands(lsp_client) return end - options.time_range = args.args or "all" + options.time_range = (args.args and args.args ~= "") or "all" options.view = "metrics" -- Send request to LSP for metrics report diff --git a/remote/internal/prettify/metrics.go b/remote/internal/prettify/metrics.go index 2d528c4..03a6145 100644 --- a/remote/internal/prettify/metrics.go +++ b/remote/internal/prettify/metrics.go @@ -27,10 +27,15 @@ func PrettifyMetrics(metrics []data.PendulumMetric) []string { // - Do this in a utility function to use with the active time display as well // iterate over each metric - for _, metric := range metrics { + for i, metric := range metrics { // TODO: redefine order? (might require hardcoding) if metric.Name != "" && len(metric.Value) != 0 { lines = append(lines, prettifyMetric(metric, args.PendulumArgs().NMetrics)) + + // Add empty line between metrics (but not after the last one) + if i < len(metrics)-1 { + lines = append(lines, "") + } } } diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 25e1a43..0000000 --- a/src/main.rs +++ /dev/null @@ -1,251 +0,0 @@ -use std::collections::HashMap; -use std::fs::{File, OpenOptions, create_dir_all}; -use std::path::Path; -use std::process::Command; - -use anyhow::{Result, anyhow}; -use clap::Parser; -use csv::{ReaderBuilder, WriterBuilder}; -use log::{error, info, warn}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tower_lsp::jsonrpc::{Error, Result as LspResult}; -use tower_lsp::lsp_types::*; -use tower_lsp::{Client, LanguageServer, LspService, Server}; - -#[derive(Parser, Debug)] -#[command(name = "pendulum-lsp")] -#[command(about = "Pendulum time tracking LSP server")] -struct Args { - /// Path to the CSV log file - #[arg(long, short)] - csv_path: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ActivityData { - active: String, - branch: String, - cwd: String, - file: String, - filetype: String, - project: String, - time: String, -} - -struct PendulumLsp { - client: Client, - csv_file_path: String, -} - -impl PendulumLsp { - fn new(client: Client, csv_path: String) -> Self { - Self { - client, - csv_file_path: csv_path, - } - } - - fn get_git_project(cwd: &str) -> String { - match Command::new("git") - .args(["config", "--local", "remote.origin.url"]) - .current_dir(cwd) - .output() - { - Ok(output) => { - let url = String::from_utf8_lossy(&output.stdout); - let trimmed = url.trim(); - if let Some(captures) = regex::Regex::new(r".*/([^.]+)\.git$") - .unwrap() - .captures(trimmed) - { - captures[1].to_string() - } else { - "unknown_project".to_string() - } - } - Err(_) => "unknown_project".to_string(), - } - } - - fn get_git_branch(cwd: &str) -> String { - match Command::new("git") - .args(["branch", "--show-current"]) - .current_dir(cwd) - .output() - { - Ok(output) => { - let branch = String::from_utf8_lossy(&output.stdout); - let trimmed = branch.trim(); - if trimmed.is_empty() || trimmed.starts_with("fatal:") { - "unknown_branch".to_string() - } else { - trimmed.to_string() - } - } - Err(_) => "unknown_branch".to_string(), - } - } - - async fn write_csv_data(&self, data: &ActivityData) -> Result<()> { - let path = &self.csv_file_path; - - // Create directory if it doesn't exist - if let Some(parent) = Path::new(path).parent() { - create_dir_all(parent)?; - } - - let file_exists = Path::new(path).exists(); - let mut file_empty = false; - - if file_exists { - let metadata = std::fs::metadata(path)?; - file_empty = metadata.len() == 0; - } - - let file = OpenOptions::new().create(true).append(true).open(path)?; - - let mut writer = WriterBuilder::new().from_writer(file); - - // Write header if file is new or empty - if !file_exists || file_empty { - writer.write_record([ - "active", "branch", "cwd", "file", "filetype", "project", "time", - ])?; - } - - // Write data row - writer.write_record([ - &data.active, - &data.branch, - &data.cwd, - &data.file, - &data.filetype, - &data.project, - &data.time, - ])?; - - writer.flush()?; - Ok(()) - } -} - -#[tower_lsp::async_trait] -impl LanguageServer for PendulumLsp { - async fn initialize(&self, _params: InitializeParams) -> LspResult { - info!( - "Pendulum LSP initializing with CSV path: {}", - self.csv_file_path - ); - - Ok(InitializeResult { - capabilities: ServerCapabilities { - execute_command_provider: Some(ExecuteCommandOptions { - commands: vec!["pendulum.logActivity".to_string()], - ..Default::default() - }), - ..Default::default() - }, - server_info: Some(ServerInfo { - name: "Pendulum LSP".to_string(), - version: Some("0.1.0".to_string()), - }), - }) - } - - async fn initialized(&self, _: InitializedParams) { - info!("Pendulum LSP initialized"); - self.client - .log_message(MessageType::INFO, "Pendulum LSP server initialized") - .await; - } - - async fn shutdown(&self) -> LspResult<()> { - info!("Pendulum LSP shutting down"); - Ok(()) - } - - async fn execute_command(&self, params: ExecuteCommandParams) -> LspResult> { - match params.command.as_str() { - "pendulum.logActivity" => { - if let Some(args) = params.arguments.first() { - match serde_json::from_value::>(args.clone()) { - Ok(data) => { - let activity = ActivityData { - time: data - .get("time") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - active: data - .get("active") - .and_then(|v| v.as_str()) - .unwrap_or("false") - .to_string(), - file: data - .get("file") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - filetype: data - .get("filetype") - .and_then(|v| v.as_str()) - .unwrap_or("unknown_filetype") - .to_string(), - cwd: data - .get("cwd") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - project: Self::get_git_project( - data.get("cwd").and_then(|v| v.as_str()).unwrap_or(""), - ), - branch: Self::get_git_branch( - data.get("cwd").and_then(|v| v.as_str()).unwrap_or(""), - ), - }; - - match self.write_csv_data(&activity).await { - Ok(_) => { - info!("Activity logged successfully"); - Ok(Some(Value::Bool(true))) - } - Err(e) => { - error!("Failed to write CSV data: {e}"); - self.client - .log_message( - MessageType::ERROR, - format!("Failed to write CSV data: {e}"), - ) - .await; - Err(Error::internal_error()) - } - } - } - Err(e) => { - error!("Failed to parse activity data: {e}"); - Err(Error::invalid_params("Invalid activity data format")) - } - } - } else { - Err(Error::invalid_params("Missing activity data")) - } - } - _ => Err(Error::method_not_found()), - } - } -} - -#[tokio::main] -async fn main() { - env_logger::init(); - - let args = Args::parse(); - - let stdin = tokio::io::stdin(); - let stdout = tokio::io::stdout(); - - let (service, socket) = - LspService::build(|client| PendulumLsp::new(client, args.csv_path)).finish(); - Server::new(stdin, stdout, socket).serve(service).await; -} From 6b8159c79303ea5c1a6fdf48e3e05ad22a080682 Mon Sep 17 00:00:00 2001 From: ptdewey <57921252+ptdewey@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:24:02 -0400 Subject: [PATCH 11/20] refactor: remove deprecated, move requires into setup for performance --- lua/pendulum/handlers.lua | 4 +-- lua/pendulum/init.lua | 52 ++++++++++++++++++--------------------- lua/pendulum/remote.lua | 20 +++++++++------ selene.toml | 4 +++ 4 files changed, 42 insertions(+), 38 deletions(-) create mode 100644 selene.toml diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 264a4b3..0103932 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -7,7 +7,7 @@ local function send_to_lsp(command, args) return end - lsp_client.request("workspace/executeCommand", { + lsp_client:request("workspace/executeCommand", { command = command, arguments = args and { args } or {}, }, function(err, _) @@ -144,7 +144,7 @@ function M.setup(opts) vim.api.nvim_create_autocmd({ "VimLeave" }, { group = "Pendulum", callback = function() - if lsp_client and not lsp_client.is_stopped() then + if lsp_client and not lsp_client:is_stopped() then log_full_activity() send_to_lsp("pendulum.endSession") end diff --git a/lua/pendulum/init.lua b/lua/pendulum/init.lua index 85d5a37..76de269 100644 --- a/lua/pendulum/init.lua +++ b/lua/pendulum/init.lua @@ -1,38 +1,34 @@ local M = {} -local handlers = require("pendulum.handlers") -local remote = require("pendulum.remote") - --- default plugin options -local default_opts = { - log_file = vim.env.HOME .. "/pendulum-log.csv", - timeout_len = 180, - timer_len = 120, - gen_reports = true, - top_n = 5, - hours_n = 10, - time_format = "12h", - time_zone = "UTC", -- Format "America/New_York" - report_excludes = { - branch = {}, - directory = {}, - file = {}, - filetype = {}, - project = {}, - }, - report_section_excludes = {}, - lsp_binary = nil, -} - ---set up plugin autocommands with user options ---@param opts table? function M.setup(opts) + local handlers = require("pendulum.handlers") + local remote = require("pendulum.remote") + + -- default plugin options + local default_opts = { + log_file = vim.env.HOME .. "/pendulum-log.csv", + timeout_len = 180, + timer_len = 120, + top_n = 5, + hours_n = 10, + time_format = "12h", + time_zone = "UTC", -- Format "America/New_York" + report_excludes = { + branch = {}, + directory = {}, + file = {}, + filetype = {}, + project = {}, + }, + report_section_excludes = {}, + lsp_binary = nil, + } + opts = vim.tbl_deep_extend("force", default_opts, opts or {}) handlers.setup(opts) - - if opts.gen_reports == true then - remote.setup(opts) - end + remote.setup(opts) end return M diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index 8b8133b..efa82bb 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -8,11 +8,15 @@ local function create_buffer(content, filetype) local buf = vim.api.nvim_create_buf(false, true) -- Set buffer options - vim.api.nvim_buf_set_option(buf, "filetype", filetype or "markdown") + vim.api.nvim_set_option_value( + "filetype", + filetype or "markdown", + { buf = buf } + ) -- Process content - LSP always returns a string local lines = {} - + if type(content) == "string" then -- Split string on newlines and add each line for line in content:gmatch("[^\r\n]+") do @@ -38,8 +42,8 @@ end -- Function to create a popup window with a buffer local function create_popup_window(buf) -- Get screen dimensions - local screen_width = vim.api.nvim_get_option("columns") - local screen_height = vim.api.nvim_get_option("lines") + local screen_width = vim.api.nvim_get_option_value("columns", {}) + local screen_height = vim.api.nvim_get_option_value("lines", {}) -- Calculate popup dimensions local popup_width = math.floor(screen_width * 0.85) @@ -88,7 +92,7 @@ end -- Setup pendulum commands for report generation local function setup_pendulum_commands(lsp_client) vim.api.nvim_create_user_command("Pendulum", function(args) - if not lsp_client or lsp_client.is_stopped() then + if not lsp_client or lsp_client:is_stopped() then vim.notify( "Pendulum LSP client not available", vim.log.levels.ERROR @@ -100,14 +104,14 @@ local function setup_pendulum_commands(lsp_client) options.view = "metrics" -- Send request to LSP for metrics report - lsp_client.request("workspace/executeCommand", { + lsp_client:request("workspace/executeCommand", { command = "pendulum.generateMetricsReport", arguments = { options }, }, handle_lsp_response) end, { nargs = "?" }) vim.api.nvim_create_user_command("PendulumHours", function() - if not lsp_client or lsp_client.is_stopped() then + if not lsp_client or lsp_client:is_stopped() then vim.notify( "Pendulum LSP client not available", vim.log.levels.ERROR @@ -118,7 +122,7 @@ local function setup_pendulum_commands(lsp_client) options.view = "hours" -- Send request to LSP for hours report - lsp_client.request("workspace/executeCommand", { + lsp_client:request("workspace/executeCommand", { command = "pendulum.generateHoursReport", arguments = { options }, }, handle_lsp_response) diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..eac3e9b --- /dev/null +++ b/selene.toml @@ -0,0 +1,4 @@ +std = "vim" + +[lints] +mixed_table = "allow" From cf91072e75a7fef57b12b10d92a3dcf7ae8cb6c1 Mon Sep 17 00:00:00 2001 From: ptdewey <57921252+ptdewey@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:11:02 -0400 Subject: [PATCH 12/20] ci: workflows --- .github/workflows/docs.yaml | 26 ++++++++++++++++++++++++++ .github/workflows/release.yaml | 22 ++++++++++++++++++++++ justfile | 12 ++++++------ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/docs.yaml create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..cf93290 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,26 @@ +name: Generate Vimdoc + +on: + push: + branches: + - main + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: panvimdoc + uses: kdheepak/panvimdoc@main + with: + vimdoc: yankbank-nvim + version: "Neovim >= 0.7.0" + demojify: true + treesitter: true + - name: Push changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: "docs: auto-generate vimdoc" + commit_user_name: "github-actions[bot]" + commit_user_email: "github-actions[bot]@users.noreply.github.com" + commit_author: "github-actions[bot] " diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..044e9c8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,22 @@ +name: Release To GitHub + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + release: + name: Release To GitHub + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + release-type: simple diff --git a/justfile b/justfile index aa131d1..97af1b5 100644 --- a/justfile +++ b/justfile @@ -3,13 +3,13 @@ default: just --list fmt: - @echo "Formatting lua/yankbank..." - @stylua lua/ --config-path=.stylua.toml - @echo "Formatting Go files in ./remote..." - @find ./remote -name '*.go' -exec gofmt -w {} + + @echo "Formatting lua/yankbank..." + @stylua lua/ --config-path=stylua.toml + @echo "Formatting Go files in ./remote..." + @find ./remote -name '*.go' -exec gofmt -w {} + lint: - @echo "Linting lua/yankbank..." - @luacheck lua/ --globals vim + @echo "Linting lua/yankbank..." + @luacheck lua/ --globals vim pr-ready: fmt lint From f8e5360ba89d183d46059fa124d62febeb225f02 Mon Sep 17 00:00:00 2001 From: ptdewey <57921252+ptdewey@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:59:46 -0400 Subject: [PATCH 13/20] fix: replace deprecated `client.is_stopped` --- lua/pendulum/handlers.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 0103932..8fa4af0 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -3,7 +3,7 @@ local M = {} local lsp_client = nil local function send_to_lsp(command, args) - if not lsp_client or lsp_client.is_stopped() then + if not lsp_client or lsp_client:is_stopped() then return end From 7b1cee4df248d217cb54439a5fd718e35d4485b6 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sat, 17 Jan 2026 14:44:13 -0500 Subject: [PATCH 14/20] feat: logging improvements and popup order fixes --- internal/config/config.go | 4 + internal/handlers/data/aggregator.go | 6 +- internal/handlers/metrics.go | 1 - internal/handlers/prettify/formatter.go | 47 ++++------ justfile | 5 +- lua/pendulum/handlers.lua | 117 ++++++++++++++++++------ 6 files changed, 123 insertions(+), 57 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index d80a87c..778a68e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,10 @@ func WithTimerLen(timer time.Duration) option { } } +func testFunc() { + return +} + func Setup(opts ...option) error { cfg = new(config) diff --git a/internal/handlers/data/aggregator.go b/internal/handlers/data/aggregator.go index c51067d..7fcdf51 100644 --- a/internal/handlers/data/aggregator.go +++ b/internal/handlers/data/aggregator.go @@ -101,7 +101,11 @@ func (a *MetricsAggregator) AggregatePendulumMetrics(ctx context.Context, data [ } metrics[result.Index] = result - case err := <-errors: + case err, ok := <-errors: + if !ok { + // Errors channel closed, ignore + continue + } if firstError == nil { firstError = err } diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go index 417a4a7..276c110 100644 --- a/internal/handlers/metrics.go +++ b/internal/handlers/metrics.go @@ -74,7 +74,6 @@ func GenerateMetricsReport(ctx *glsp.Context, args []any) (string, error) { "**Processing Summary:**", "- Rows processed: " + formatNumber(result.Processed), "- Processing time: " + result.Duration.String(), - "- Metrics generated: " + formatNumber(len(result.Metrics)), } formattedLines = append(formattedLines, metadata...) diff --git a/internal/handlers/prettify/formatter.go b/internal/handlers/prettify/formatter.go index d194bbe..efa33d5 100644 --- a/internal/handlers/prettify/formatter.go +++ b/internal/handlers/prettify/formatter.go @@ -27,7 +27,7 @@ func (f *MetricsFormatter) FormatMetrics(metrics []data.PendulumMetric) []string var lines []string // Add header with metadata - header := f.generateHeader(len(metrics)) + header := f.generateHeader() if header != "" { lines = append(lines, header) } @@ -49,17 +49,16 @@ func (f *MetricsFormatter) FormatMetrics(metrics []data.PendulumMetric) []string } // generateHeader creates a header with report metadata -func (f *MetricsFormatter) generateHeader(metricCount int) string { +func (f *MetricsFormatter) generateHeader() string { var parts []string parts = append(parts, "# Pendulum Metrics Report") parts = append(parts, fmt.Sprintf("**Generated:** %s", time.Now().Format("2006-01-02 15:04:05"))) parts = append(parts, fmt.Sprintf("**Time Range:** %s", f.params.TimeRange)) parts = append(parts, fmt.Sprintf("**Log File:** %s", truncateHome(f.params.LogFile))) - parts = append(parts, fmt.Sprintf("**Metrics:** %d", metricCount)) parts = append(parts, "") - return strings.Join(parts, "\n\n") + return strings.Join(parts, "\n") } // formatMetric converts a single PendulumMetric struct into a formatted string @@ -85,23 +84,24 @@ func (f *MetricsFormatter) formatMetric(metric data.PendulumMetric, n int) strin // Generate formatted output name := f.titleCase(metric.Name) - out := fmt.Sprintf("## Top %d %s\n", n, f.prettifyMetricName(name)) + var out strings.Builder + fmt.Fprintf(&out, "## Top %d %s\n", n, f.prettifyMetricName(name)) for i := 0; i < n; i++ { entry := metric.Value[keys[i]] if math.IsNaN(entry.ActivePct) { continue } - out += f.formatEntry(entry, i+1, maxIDLen, n) + "\n" + out.WriteString(f.formatEntry(entry, i+1, maxIDLen, n) + "\n") } - return out + return out.String() } // formatEntry converts a single PendulumEntry into a formatted string func (f *MetricsFormatter) formatEntry(e *data.PendulumEntry, rank int, maxIDLen int, totalRanks int) string { rankWidth := len(fmt.Sprintf("%d", totalRanks)) - format := fmt.Sprintf("%%%dd. %%-%ds: Total %%+6s, Active %%+6s (%%-5.2f%%%%)", + format := fmt.Sprintf("%%%dd. %%-%ds: Total %%6s, Active %%6s (%%-5.2f%%%%)", rankWidth, maxIDLen+1) return fmt.Sprintf(format, @@ -173,26 +173,19 @@ func (f *MetricsFormatter) formatDuration(d time.Duration) string { return "0s" } - hours := int(d.Hours()) - minutes := int(d.Minutes()) % 60 - seconds := int(d.Seconds()) % 60 - - if hours > 0 { - if f.params.TimeFormat == "24h" || hours >= 24 { - return fmt.Sprintf("%dh%02dm", hours, minutes) - } - // 12h format - if hours > 12 { - return fmt.Sprintf("%dh%02dm", hours-12, minutes) - } - return fmt.Sprintf("%dh%02dm", hours, minutes) + if d >= 24*time.Hour { + days := float64(d) / float64(24*time.Hour) + return fmt.Sprintf("%.2fd", days) + } else if d >= time.Hour { + hours := float64(d) / float64(time.Hour) + return fmt.Sprintf("%.2fh", hours) + } else if d >= time.Minute { + minutes := float64(d) / float64(time.Minute) + return fmt.Sprintf("%.2fm", minutes) + } else { + seconds := float64(d) / float64(time.Second) + return fmt.Sprintf("%.2fs", seconds) } - - if minutes > 0 { - return fmt.Sprintf("%dm%02ds", minutes, seconds) - } - - return fmt.Sprintf("%ds", seconds) } // titleCase converts a string to title case diff --git a/justfile b/justfile index 97af1b5..0c603d4 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,6 @@ [private] default: - just --list + go build fmt: @echo "Formatting lua/yankbank..." @@ -13,3 +13,6 @@ lint: @luacheck lua/ --globals vim pr-ready: fmt lint + +test: + @go test ./... -cover -coverprofile=cover.out diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 8fa4af0..41e59e3 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -1,9 +1,40 @@ local M = {} local lsp_client = nil +local lsp_ready = false +local message_queue = {} + +local function flush_queue() + if not lsp_ready or not lsp_client or lsp_client:is_stopped() then + return + end + + for _, msg in ipairs(message_queue) do + lsp_client:request("workspace/executeCommand", { + command = msg.command, + arguments = msg.args and { msg.args } or {}, + }, function(err, _) + if err then + vim.notify( + "Failed to execute " + .. msg.command + .. ": " + .. tostring(err.message or err), + vim.log.levels.ERROR + ) + end + end, 0) + end + + message_queue = {} +end local function send_to_lsp(command, args) - if not lsp_client or lsp_client:is_stopped() then + local msg = { command = command, args = args } + + if not lsp_ready or not lsp_client or lsp_client:is_stopped() then + -- Queue the message to send when connection is ready + table.insert(message_queue, msg) return end @@ -23,6 +54,30 @@ local function send_to_lsp(command, args) end, 0) end +local function ping_activity() + send_to_lsp("pendulum.activityPing") +end + +local function log_full_activity(filepath) + -- Use provided filepath or get current buffer's path + local file = filepath or vim.fn.expand("%:p") + if file == "" then + return + end + + local ft = vim.bo.filetype ~= "" and vim.bo.filetype or "unknown_filetype" + + local data = { + time = os.date("!%Y-%m-%d %H:%M:%S"), + active = true, + file = file, + filetype = ft, + cwd = vim.loop.cwd(), + } + + send_to_lsp("pendulum.logActivity", data) +end + local function init_lsp_client(opts) if lsp_client then return lsp_client @@ -59,11 +114,22 @@ local function init_lsp_client(opts) }, root_dir = vim.loop.cwd(), filetypes = {}, + on_init = function(client, initialize_result) + -- LSP handshake complete - server is ready to receive commands + -- Set lsp_client immediately since on_init fires before vim.lsp.start() returns + lsp_client = client + lsp_ready = true + -- Flush any queued messages + vim.schedule(function() + flush_queue() + end) + end, on_attach = function(client, bufnr) vim.lsp.log.debug("Pendulum LSP attached") end, on_exit = function(code, signal, _) lsp_client = nil + lsp_ready = false vim.notify( string.format( "Pendulum LSP server exited with code: %s, signal: %s", @@ -91,26 +157,6 @@ local function init_lsp_client(opts) return lsp_client end -local function ping_activity() - send_to_lsp("pendulum.activityPing") -end - -local function log_full_activity() - local ft = vim.bo.filetype ~= "" and vim.bo.filetype or "unknown_filetype" - - local data = { - time = os.date("!%Y-%m-%d %H:%M:%S"), - active = true, - file = vim.fn.expand("%:p"), - filetype = ft, - cwd = vim.loop.cwd(), - } - - if data.file ~= "" then - send_to_lsp("pendulum.logActivity", data) - end -end - function M.get_lsp_client() return lsp_client end @@ -136,9 +182,28 @@ function M.setup(opts) callback = ping_activity, }) - vim.api.nvim_create_autocmd({ "BufEnter" }, { + vim.api.nvim_create_autocmd( + { "BufReadPost", "BufEnter", "BufLeave" }, + { + group = "Pendulum", + callback = function() + log_full_activity() + end, + } + ) + + -- VimEnter needs special handling - the buffer may not be ready yet + vim.api.nvim_create_autocmd({ "VimEnter" }, { group = "Pendulum", - callback = log_full_activity, + callback = function() + -- Defer to ensure buffer is loaded when opening nvim with a file argument + vim.defer_fn(function() + local file = vim.fn.expand("%:p") + if file ~= "" then + log_full_activity(file) + end + end, 10) + end, }) vim.api.nvim_create_autocmd({ "VimLeave" }, { @@ -151,10 +216,8 @@ function M.setup(opts) end, }) - -- Initialize LSP client immediately (deferred to next tick to avoid start-up error) - vim.defer_fn(function() - init_lsp_client(opts) - end, 0) + -- Initialize LSP client immediately + init_lsp_client(opts) end return M From 8deea6ee858a706b5208c26dfdaf7e54aaa95907 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sat, 17 Jan 2026 15:52:15 -0500 Subject: [PATCH 15/20] feat: time range reports --- internal/handlers/data/aggregator.go | 24 +++++---- internal/handlers/data/utils.go | 75 +++++++++++++++++++--------- internal/handlers/data/utils_test.go | 49 ++++++++++++++++++ lua/pendulum/remote.lua | 7 ++- 4 files changed, 120 insertions(+), 35 deletions(-) diff --git a/internal/handlers/data/aggregator.go b/internal/handlers/data/aggregator.go index 7fcdf51..3316eda 100644 --- a/internal/handlers/data/aggregator.go +++ b/internal/handlers/data/aggregator.go @@ -193,6 +193,12 @@ func (a *MetricsAggregator) aggregateMetric(data [][]string, colIdx int, colName } } + // Create time range filter once (pre-computes boundaries) + timeFilter, err := NewTimeRangeFilter(a.params.TimeRange, a.params.TimeZone) + if err != nil { + return metric, err + } + // Process each row for i := 1; i < len(data); i++ { if len(data[i]) <= colIdx || len(data[i]) <= timecol { @@ -205,16 +211,14 @@ func (a *MetricsAggregator) aggregateMetric(data [][]string, colIdx int, colName continue } - // Check time range - if a.params.TimeRange != "all" { - inRange, err := IsTimestampInRange(data[i][timecol], a.params.TimeRange, a.params.TimeZone) - if err != nil { - log.Printf("Error checking timestamp range: %v", err) - continue - } - if !inRange { - continue - } + // Check time range using pre-computed filter + inRange, err := timeFilter.InRange(data[i][timecol]) + if err != nil { + log.Printf("Error checking timestamp range: %v", err) + continue + } + if !inRange { + continue } val := data[i][colIdx] diff --git a/internal/handlers/data/utils.go b/internal/handlers/data/utils.go index ff4704e..2760c95 100644 --- a/internal/handlers/data/utils.go +++ b/internal/handlers/data/utils.go @@ -114,25 +114,24 @@ func IsExcluded(val string, patterns []*regexp.Regexp) bool { return false } -// IsTimestampInRange checks if a timestamp falls within the specified time range -func IsTimestampInRange(timestampStr, rangeType, timeZone string) (bool, error) { - layout := "2006-01-02 15:04:05" +// TimeRangeFilter provides efficient time range filtering with pre-computed boundaries +type TimeRangeFilter struct { + startOfRange time.Time + endOfRange time.Time + loc *time.Location + layout string + isAll bool +} - timestamp, err := time.Parse(layout, timestampStr) - if err != nil { - return false, &MetricsError{ - Type: ErrParsingFailed, - Message: fmt.Sprintf("failed to parse timestamp: %s", timestampStr), - Cause: err, - } +// NewTimeRangeFilter creates a filter with pre-computed time boundaries +func NewTimeRangeFilter(rangeType, timeZone string) (*TimeRangeFilter, error) { + if rangeType == "all" { + return &TimeRangeFilter{isAll: true}, nil } - // Load timezone loc, err := time.LoadLocation(timeZone) if err != nil { - loc = time.UTC // Fallback to UTC - } else { - timestamp = timestamp.In(loc) + loc = time.UTC } now := time.Now().In(loc) @@ -141,27 +140,55 @@ func IsTimestampInRange(timestampStr, rangeType, timeZone string) (bool, error) switch rangeType { case "today", "day": startOfRange = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) - endOfRange = startOfRange.Add(24 * time.Hour).Add(-time.Nanosecond) + endOfRange = startOfRange.Add(24 * time.Hour) case "year": startOfRange = time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc) - endOfRange = startOfRange.AddDate(1, 0, 0).Add(-time.Nanosecond) + endOfRange = startOfRange.AddDate(1, 0, 0) case "month": startOfRange = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) - endOfRange = startOfRange.AddDate(0, 1, 0).Add(-time.Nanosecond) + endOfRange = startOfRange.AddDate(0, 1, 0) case "week": - startOfRange = now.AddDate(0, 0, -6) - endOfRange = now.Add(24*time.Hour - time.Nanosecond) + startOfRange = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -6) + endOfRange = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).Add(24 * time.Hour) case "hour": startOfRange = now.Truncate(time.Hour) - endOfRange = startOfRange.Add(time.Hour).Add(-time.Nanosecond) - case "all": - return true, nil + endOfRange = startOfRange.Add(time.Hour) default: - return false, &MetricsError{ + return nil, &MetricsError{ Type: ErrInvalidParameters, Message: fmt.Sprintf("unsupported time range: %s", rangeType), } } - return timestamp.After(startOfRange) && timestamp.Before(endOfRange), nil + return &TimeRangeFilter{ + startOfRange: startOfRange, + endOfRange: endOfRange, + loc: loc, + layout: "2006-01-02 15:04:05", + isAll: false, + }, nil +} + +// InRange checks if a timestamp string falls within the pre-computed range +func (f *TimeRangeFilter) InRange(timestampStr string) (bool, error) { + if f.isAll { + return true, nil + } + + timestamp, err := time.ParseInLocation(f.layout, timestampStr, f.loc) + if err != nil { + return false, err + } + + return !timestamp.Before(f.startOfRange) && timestamp.Before(f.endOfRange), nil +} + +// IsTimestampInRange checks if a timestamp falls within the specified time range +// Deprecated: Use TimeRangeFilter for better performance when checking multiple timestamps +func IsTimestampInRange(timestampStr, rangeType, timeZone string) (bool, error) { + filter, err := NewTimeRangeFilter(rangeType, timeZone) + if err != nil { + return false, err + } + return filter.InRange(timestampStr) } diff --git a/internal/handlers/data/utils_test.go b/internal/handlers/data/utils_test.go index 0ca913b..627d60b 100644 --- a/internal/handlers/data/utils_test.go +++ b/internal/handlers/data/utils_test.go @@ -208,6 +208,55 @@ func TestIsTimestampInRange(t *testing.T) { } } +func TestIsTimestampInRange_Boundaries(t *testing.T) { + // Test boundary conditions using fixed times relative to a known "now" + // We'll test the logic by creating timestamps that should definitely be in or out of range + + now := time.Now() + loc := time.UTC + layout := "2006-01-02 15:04:05" + + // Generate test timestamps + startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + midToday := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, loc) + yesterday := startOfToday.AddDate(0, 0, -1) + tomorrow := startOfToday.AddDate(0, 0, 1) + + tests := []struct { + name string + timestamp time.Time + rangeType string + expected bool + }{ + // Day tests + {"start of today is in day range", startOfToday, "day", true}, + {"mid today is in day range", midToday, "day", true}, + {"yesterday is not in day range", yesterday, "day", false}, + {"tomorrow is not in day range", tomorrow, "day", false}, + + // Week tests (last 7 days including today) + {"today is in week range", midToday, "week", true}, + {"6 days ago is in week range", startOfToday.AddDate(0, 0, -6).Add(time.Hour), "week", true}, + {"8 days ago is not in week range", startOfToday.AddDate(0, 0, -8), "week", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timestampStr := tt.timestamp.In(loc).Format(layout) + result, err := IsTimestampInRange(timestampStr, tt.rangeType, "UTC") + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result != tt.expected { + t.Errorf("For timestamp %s with range %s: expected %v, got %v", + timestampStr, tt.rangeType, tt.expected, result) + } + }) + } +} + func BenchmarkTimeDiff(b *testing.B) { timestamps := []string{"2024-01-01 10:00:00", "2024-01-01 10:01:00"} diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index efa82bb..d113741 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -100,7 +100,12 @@ local function setup_pendulum_commands(lsp_client) return end - options.time_range = (args.args and args.args ~= "") or "all" + -- Parse time range from command argument (e.g., :Pendulum week) + local time_range = "all" + if args.args and args.args ~= "" then + time_range = args.args + end + options.time_range = time_range options.view = "metrics" -- Send request to LSP for metrics report From c3cd2b7e0ee2994a7afe691797fb6d4c3c3e6df9 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sat, 17 Jan 2026 15:59:11 -0500 Subject: [PATCH 16/20] feat: shutter for snapshot testing --- go.mod | 2 + go.sum | 4 + .../__snapshots__/formatter_direct.snap | 20 ++ .../__snapshots__/metrics_report_all.snap | 47 +++ .../metrics_report_exclusions.snap | 28 ++ .../__snapshots__/metrics_report_top2.snap | 35 +++ internal/handlers/data/utils_test.go | 178 ++++++++++++ internal/handlers/snapshot_test.go | 268 ++++++++++++++++++ lua/pendulum/remote.lua | 17 +- testdata/synthetic_log.csv | 109 +++++++ 10 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 internal/handlers/__snapshots__/formatter_direct.snap create mode 100644 internal/handlers/__snapshots__/metrics_report_all.snap create mode 100644 internal/handlers/__snapshots__/metrics_report_exclusions.snap create mode 100644 internal/handlers/__snapshots__/metrics_report_top2.snap create mode 100644 internal/handlers/snapshot_test.go create mode 100644 testdata/synthetic_log.csv diff --git a/go.mod b/go.mod index b9dcfa0..9e99ed4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ptdewey/pendulum-server go 1.24.4 require ( + github.com/ptdewey/shutter v0.1.4 github.com/tliron/commonlog v0.2.8 github.com/tliron/glsp v0.2.2 ) @@ -11,6 +12,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/iancoleman/strcase v0.3.0 // indirect + github.com/kortschak/utter v1.7.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect diff --git a/go.sum b/go.sum index 32646c4..61eefd9 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/ github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/kortschak/utter v1.7.0 h1:6NKMynvGUyqfeMTawfah4zyInlrgwzjkDAHrT+skx/w= +github.com/kortschak/utter v1.7.0/go.mod h1:vSmSjbyrlKjjsL71193LmzBOKgwePk9DH6uFaWHIInc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -17,6 +19,8 @@ github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/ptdewey/shutter v0.1.4 h1:tMTNMTxCpA1F0REyi+taztoHVe9EpB5sSKhaIBzYu1c= +github.com/ptdewey/shutter v0.1.4/go.mod h1:teeIXF4LdgsE9E4kjHk9nGzDxl2cjdbVb1qbdzAHSR4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= diff --git a/internal/handlers/__snapshots__/formatter_direct.snap b/internal/handlers/__snapshots__/formatter_direct.snap new file mode 100644 index 0000000..4acc982 --- /dev/null +++ b/internal/handlers/__snapshots__/formatter_direct.snap @@ -0,0 +1,20 @@ +--- +title: formatter_direct +test_name: TestFormatterSnapshotDirectly +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Metrics Report +**Generated:** +**Time Range:** all +**Log File:** /test/pendulum.csv + +## Top 2 Branches +1. main : Total 30.00m, Active 25.00m (83.30%) +2. feature/auth : Total 20.00m, Active 18.00m (90.00%) + + +## Top 2 Projects +1. myproject : Total 45.00m, Active 40.00m (88.90%) +2. webapp : Total 15.00m, Active 12.00m (80.00%) + diff --git a/internal/handlers/__snapshots__/metrics_report_all.snap b/internal/handlers/__snapshots__/metrics_report_all.snap new file mode 100644 index 0000000..5918838 --- /dev/null +++ b/internal/handlers/__snapshots__/metrics_report_all.snap @@ -0,0 +1,47 @@ +--- +title: metrics_report_all +test_name: TestMetricsReportSnapshot +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Metrics Report +**Generated:** +**Time Range:** all +**Log File:** + +## Top 4 Branches +1. main : Total 45.00m, Active 37.00m (82.22%) +2. develop : Total 26.00m, Active 23.00m (88.46%) +3. feature/auth : Total 15.00m, Active 13.00m (86.67%) +4. bugfix/login : Total 8.00m, Active 7.00m (87.50%) + + +## Top 4 Directories +1. /home/dev/myproject : Total 39.00m, Active 32.00m (82.05%) +2. /home/dev/api-server : Total 26.00m, Active 23.00m (88.46%) +3. /home/dev/webapp : Total 25.00m, Active 22.00m (88.00%) +4. /home/dev/notes : Total 4.00m, Active 3.00m (75.00%) + + +## Top 5 Files +1. auth.go : Total 18.00m, Active 16.00m (88.89%) +2. server.py : Total 12.00m, Active 11.00m (91.67%) +3. app.js : Total 10.00m, Active 9.00m (90.00%) +4. routes.py : Total 9.00m, Active 8.00m (88.89%) +5. index.html : Total 8.00m, Active 7.00m (87.50%) + + +## Top 5 File Types +1. go : Total 33.00m, Active 27.00m (81.82%) +2. python : Total 26.00m, Active 23.00m (88.46%) +3. javascript : Total 10.00m, Active 9.00m (90.00%) +4. markdown : Total 10.00m, Active 8.00m (80.00%) +5. html : Total 8.00m, Active 7.00m (87.50%) + + +## Top 4 Projects +1. myproject : Total 39.00m, Active 32.00m (82.05%) +2. api-server : Total 26.00m, Active 23.00m (88.46%) +3. webapp : Total 25.00m, Active 22.00m (88.00%) +4. notes : Total 4.00m, Active 3.00m (75.00%) + diff --git a/internal/handlers/__snapshots__/metrics_report_exclusions.snap b/internal/handlers/__snapshots__/metrics_report_exclusions.snap new file mode 100644 index 0000000..5688801 --- /dev/null +++ b/internal/handlers/__snapshots__/metrics_report_exclusions.snap @@ -0,0 +1,28 @@ +--- +title: metrics_report_exclusions +test_name: TestMetricsReportSnapshotWithExclusions +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Metrics Report +**Generated:** +**Time Range:** all +**Log File:** + +## Top 3 Files +1. auth.go : Total 18.00m, Active 16.00m (88.89%) +2. server.py : Total 12.00m, Active 11.00m (91.67%) +3. app.js : Total 10.00m, Active 9.00m (90.00%) + + +## Top 3 File Types +1. go : Total 33.00m, Active 27.00m (81.82%) +2. python : Total 26.00m, Active 23.00m (88.46%) +3. javascript : Total 10.00m, Active 9.00m (90.00%) + + +## Top 3 Projects +1. myproject : Total 39.00m, Active 32.00m (82.05%) +2. api-server : Total 26.00m, Active 23.00m (88.46%) +3. webapp : Total 25.00m, Active 22.00m (88.00%) + diff --git a/internal/handlers/__snapshots__/metrics_report_top2.snap b/internal/handlers/__snapshots__/metrics_report_top2.snap new file mode 100644 index 0000000..cfff653 --- /dev/null +++ b/internal/handlers/__snapshots__/metrics_report_top2.snap @@ -0,0 +1,35 @@ +--- +title: metrics_report_top2 +test_name: TestMetricsReportSnapshotTopN +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Metrics Report +**Generated:** +**Time Range:** all +**Log File:** + +## Top 2 Branches +1. main : Total 45.00m, Active 37.00m (82.22%) +2. develop : Total 26.00m, Active 23.00m (88.46%) + + +## Top 2 Directories +1. /home/dev/myproject : Total 39.00m, Active 32.00m (82.05%) +2. /home/dev/api-server : Total 26.00m, Active 23.00m (88.46%) + + +## Top 2 Files +1. auth.go : Total 18.00m, Active 16.00m (88.89%) +2. server.py : Total 12.00m, Active 11.00m (91.67%) + + +## Top 2 File Types +1. go : Total 33.00m, Active 27.00m (81.82%) +2. python : Total 26.00m, Active 23.00m (88.46%) + + +## Top 2 Projects +1. myproject : Total 39.00m, Active 32.00m (82.05%) +2. api-server : Total 26.00m, Active 23.00m (88.46%) + diff --git a/internal/handlers/data/utils_test.go b/internal/handlers/data/utils_test.go index 627d60b..5478dce 100644 --- a/internal/handlers/data/utils_test.go +++ b/internal/handlers/data/utils_test.go @@ -274,3 +274,181 @@ func BenchmarkCompileRegexPatterns(b *testing.B) { _, _ = CompileRegexPatterns(patterns) } } + +func TestTimeRangeFilter(t *testing.T) { + t.Run("all range", func(t *testing.T) { + filter, err := NewTimeRangeFilter("all", "UTC") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Any timestamp should be in range for "all" + inRange, err := filter.InRange("2020-01-01 00:00:00") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !inRange { + t.Error("Expected 'all' filter to include any timestamp") + } + }) + + t.Run("invalid range type", func(t *testing.T) { + _, err := NewTimeRangeFilter("invalid", "UTC") + if err == nil { + t.Error("Expected error for invalid range type") + } + }) + + t.Run("invalid timezone falls back to UTC", func(t *testing.T) { + filter, err := NewTimeRangeFilter("day", "Invalid/Timezone") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Should not error, just use UTC + if filter.loc.String() != "UTC" { + t.Errorf("Expected UTC fallback, got %s", filter.loc.String()) + } + }) +} + +func TestTimeRangeFilter_Boundaries(t *testing.T) { + loc := time.UTC + layout := "2006-01-02 15:04:05" + now := time.Now().In(loc) + + // Generate boundary timestamps + startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + endOfToday := startOfToday.Add(24*time.Hour - time.Nanosecond) + midToday := startOfToday.Add(12 * time.Hour) + yesterday := startOfToday.AddDate(0, 0, -1) + tomorrow := startOfToday.AddDate(0, 0, 1) + + startOfWeek := startOfToday.AddDate(0, 0, -6) + beforeWeek := startOfWeek.Add(-time.Hour) + + startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) + lastMonth := startOfMonth.AddDate(0, 0, -1) + + startOfYear := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc) + lastYear := startOfYear.AddDate(0, 0, -1) + + tests := []struct { + name string + rangeType string + timestamp time.Time + expected bool + }{ + // Day range tests + {"day: start of today (inclusive)", "day", startOfToday, true}, + {"day: mid today", "day", midToday, true}, + {"day: end of today", "day", endOfToday, true}, + {"day: yesterday excluded", "day", yesterday, false}, + {"day: tomorrow excluded", "day", tomorrow, false}, + + // Week range tests (last 7 days) + {"week: today included", "week", midToday, true}, + {"week: start of week (inclusive)", "week", startOfWeek, true}, + {"week: 6 days ago mid-day", "week", startOfWeek.Add(12 * time.Hour), true}, + {"week: before week excluded", "week", beforeWeek, false}, + + // Month range tests + {"month: today included", "month", midToday, true}, + {"month: start of month (inclusive)", "month", startOfMonth, true}, + {"month: last month excluded", "month", lastMonth, false}, + + // Year range tests + {"year: today included", "year", midToday, true}, + {"year: start of year (inclusive)", "year", startOfYear, true}, + {"year: last year excluded", "year", lastYear, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter, err := NewTimeRangeFilter(tt.rangeType, "UTC") + if err != nil { + t.Fatalf("Failed to create filter: %v", err) + } + + timestampStr := tt.timestamp.Format(layout) + result, err := filter.InRange(timestampStr) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result != tt.expected { + t.Errorf("For %s with range %s: expected %v, got %v (timestamp: %s, start: %s, end: %s)", + tt.name, tt.rangeType, tt.expected, result, + timestampStr, + filter.startOfRange.Format(layout), + filter.endOfRange.Format(layout)) + } + }) + } +} + +func TestTimeRangeFilter_Timezones(t *testing.T) { + // Test that timezone handling works correctly + layout := "2006-01-02 15:04:05" + + // Create a filter for Eastern time + filter, err := NewTimeRangeFilter("day", "America/New_York") + if err != nil { + t.Fatalf("Failed to create filter: %v", err) + } + + // Get current time in Eastern + eastern, _ := time.LoadLocation("America/New_York") + now := time.Now().In(eastern) + midToday := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, eastern) + + // This timestamp should be in range + timestampStr := midToday.Format(layout) + inRange, err := filter.InRange(timestampStr) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !inRange { + t.Errorf("Expected mid-day Eastern timestamp to be in range") + } +} + +func TestTimeRangeFilter_InvalidTimestamp(t *testing.T) { + filter, err := NewTimeRangeFilter("day", "UTC") + if err != nil { + t.Fatalf("Failed to create filter: %v", err) + } + + _, err = filter.InRange("not-a-timestamp") + if err == nil { + t.Error("Expected error for invalid timestamp") + } +} + +func BenchmarkTimeRangeFilter_InRange(b *testing.B) { + filter, _ := NewTimeRangeFilter("week", "UTC") + timestamp := time.Now().Format("2006-01-02 15:04:05") + + b.ResetTimer() + for b.Loop() { + _, _ = filter.InRange(timestamp) + } +} + +func BenchmarkTimeRangeFilter_VsIsTimestampInRange(b *testing.B) { + timestamp := time.Now().Format("2006-01-02 15:04:05") + + b.Run("TimeRangeFilter (reused)", func(b *testing.B) { + filter, _ := NewTimeRangeFilter("week", "America/New_York") + b.ResetTimer() + for b.Loop() { + _, _ = filter.InRange(timestamp) + } + }) + + b.Run("IsTimestampInRange (creates filter each time)", func(b *testing.B) { + b.ResetTimer() + for b.Loop() { + _, _ = IsTimestampInRange(timestamp, "week", "America/New_York") + } + }) +} diff --git a/internal/handlers/snapshot_test.go b/internal/handlers/snapshot_test.go new file mode 100644 index 0000000..bd148ce --- /dev/null +++ b/internal/handlers/snapshot_test.go @@ -0,0 +1,268 @@ +package handlers + +import ( + "context" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/ptdewey/pendulum-server/internal/handlers/data" + "github.com/ptdewey/pendulum-server/internal/handlers/prettify" + "github.com/ptdewey/shutter" +) + +// Snapshot Testing Documentation: +// +// This file contains snapshot tests for the metrics report generation pipeline. +// Snapshot testing uses shutter (https://github.com/ptdewey/shutter) to capture +// and validate the full formatted output of reports. +// +// Key Features: +// - Deterministic Test Data: Uses testdata/synthetic_log.csv with fixed, unique +// active time values to ensure consistent, non-flaky test results +// - Scrubbers: Dynamic content (timestamps, file paths) is scrubbed to a placeholder +// before comparison to avoid false positives when environments differ +// - Full Pipeline Testing: Tests verify the complete flow: CSV read → Aggregation → +// Formatting → Report generation +// - Isolated Formatter Testing: Direct formatter tests validate output formatting +// without reliance on CSV data +// +// Test Data Details (testdata/synthetic_log.csv): +// - 106 lines (header + 105 data rows) +// - All entries dated 2024-06-15 with 1-minute intervals +// - Unique active time durations per metric to prevent non-deterministic sorting +// - Branches: main (37m), develop (22m), feature/auth (13m), bugfix/login (7m) +// - Projects: myproject (32m), webapp (22m), api-server (22m), notes (3m) +// - Files with unique durations: auth.go (16m), server.py (11m), app.js (9m), etc. +// +// Maintaining Tests: +// 1. If test output changes unexpectedly, review the diff carefully +// 2. If changes are intentional, update snapshots by running: +// go test ./internal/handlers -run Snapshot +// 3. Verify the updated snapshots in __snapshots__/ directory +// 4. Ensure all unique time values in test data remain unique to prevent +// non-deterministic sorting issues +// +// Scrubbers Applied: +// - Timestamp pattern: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} → +// - File path: .+synthetic_log\.csv → + +// getTestdataPath returns the absolute path to the testdata directory +func getTestdataPath() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "..", "..", "testdata") +} + +// TestMetricsReportSnapshot tests the full metrics report output using snapshot testing +func TestMetricsReportSnapshot(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params for the report + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 5, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, // 3 minutes - entries in test data are 1 min apart + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate metrics + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumMetrics(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate metrics: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatMetrics(result.Metrics) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers for dynamic content + shutter.SnapString(t, "metrics_report_all", report, + // Scrub the generated timestamp (format: 2006-01-02 15:04:05) + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + // Scrub the log file path which varies by environment + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestMetricsReportSnapshotWithExclusions tests report with section exclusions +func TestMetricsReportSnapshotWithExclusions(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params with exclusions + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 3, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{"branch", "directory"}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate metrics + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumMetrics(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate metrics: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatMetrics(result.Metrics) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers + shutter.SnapString(t, "metrics_report_exclusions", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestMetricsReportSnapshotTopN tests report with different TopN values +func TestMetricsReportSnapshotTopN(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params with TopN = 2 + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 2, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate metrics + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumMetrics(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate metrics: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatMetrics(result.Metrics) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers + shutter.SnapString(t, "metrics_report_top2", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestFormatterSnapshotDirectly tests the formatter output directly without full pipeline +func TestFormatterSnapshotDirectly(t *testing.T) { + // Create synthetic metrics data directly + metrics := []data.PendulumMetric{ + { + Name: "branch", + Value: map[string]*data.PendulumEntry{ + "main": { + ID: "main", + TotalTime: 30 * time.Minute, + ActiveTime: 25 * time.Minute, + ActivePct: 0.833, + }, + "feature/auth": { + ID: "feature/auth", + TotalTime: 20 * time.Minute, + ActiveTime: 18 * time.Minute, + ActivePct: 0.90, + }, + }, + }, + { + Name: "project", + Value: map[string]*data.PendulumEntry{ + "myproject": { + ID: "myproject", + TotalTime: 45 * time.Minute, + ActiveTime: 40 * time.Minute, + ActivePct: 0.889, + }, + "webapp": { + ID: "webapp", + TotalTime: 15 * time.Minute, + ActiveTime: 12 * time.Minute, + ActivePct: 0.80, + }, + }, + }, + } + + params := &data.MetricsParams{ + LogFile: "/test/pendulum.csv", + TopN: 5, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + } + + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatMetrics(metrics) + + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + shutter.SnapString(t, "formatter_direct", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + ) +} diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index d113741..f71ebcd 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -91,6 +91,9 @@ end -- Setup pendulum commands for report generation local function setup_pendulum_commands(lsp_client) + -- Valid time range options for command completion + local time_range_options = { "all", "day", "week", "month", "year", "hour" } + vim.api.nvim_create_user_command("Pendulum", function(args) if not lsp_client or lsp_client:is_stopped() then vim.notify( @@ -113,7 +116,19 @@ local function setup_pendulum_commands(lsp_client) command = "pendulum.generateMetricsReport", arguments = { options }, }, handle_lsp_response) - end, { nargs = "?" }) + end, { + nargs = "?", + complete = function(arg_lead, cmd_line, cursor_pos) + -- Filter options based on what user has typed + local matches = {} + for _, opt in ipairs(time_range_options) do + if opt:find("^" .. arg_lead) then + table.insert(matches, opt) + end + end + return matches + end, + }) vim.api.nvim_create_user_command("PendulumHours", function() if not lsp_client or lsp_client:is_stopped() then diff --git a/testdata/synthetic_log.csv b/testdata/synthetic_log.csv new file mode 100644 index 0000000..78473cf --- /dev/null +++ b/testdata/synthetic_log.csv @@ -0,0 +1,109 @@ +active,branch,directory,file,filetype,project,time +true,main,/home/dev/myproject,main.go,go,myproject,2024-06-15 09:00:00 +true,main,/home/dev/myproject,main.go,go,myproject,2024-06-15 09:01:00 +true,main,/home/dev/myproject,main.go,go,myproject,2024-06-15 09:02:00 +false,main,/home/dev/myproject,main.go,go,myproject,2024-06-15 09:03:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:10:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:11:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:12:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:13:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:14:00 +false,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:15:00 +true,main,/home/dev/myproject,utils.go,go,myproject,2024-06-15 09:30:00 +true,main,/home/dev/myproject,utils.go,go,myproject,2024-06-15 09:31:00 +false,main,/home/dev/myproject,utils.go,go,myproject,2024-06-15 09:32:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:00:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:01:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:02:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:03:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:04:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:05:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:06:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:07:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:08:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:09:00 +false,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:10:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:30:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:31:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:32:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:33:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:34:00 +false,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:35:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:00:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:01:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:02:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:03:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:04:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:05:00 +false,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:06:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:00:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:01:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:02:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:03:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:04:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:05:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:06:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:07:00 +false,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:08:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:30:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:31:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:32:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:33:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:34:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:35:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:36:00 +false,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:37:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:00:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:01:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:02:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:03:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:04:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:05:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:06:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:07:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:08:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:09:00 +false,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:10:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:00:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:01:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:02:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:03:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:04:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:05:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:06:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:07:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:08:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:09:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:10:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:11:00 +false,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:12:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:30:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:31:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:32:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:33:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:34:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:35:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:36:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:37:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:38:00 +false,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:39:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:00:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:01:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:02:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:03:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:04:00 +false,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:05:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:30:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:31:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:32:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:33:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:34:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:35:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:36:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:37:00 +false,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:38:00 +true,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:00:00 +true,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:01:00 +true,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:02:00 +true,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:03:00 +false,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:04:00 From 98b80586fd49a37028f19214524c1dd3d06d4766 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sat, 17 Jan 2026 16:50:21 -0500 Subject: [PATCH 17/20] feat: PendulumHours port to v2 --- .../__snapshots__/hours_formatter_direct.snap | 16 ++ .../__snapshots__/hours_report_12h.snap | 18 ++ .../__snapshots__/hours_report_all.snap | 18 ++ .../__snapshots__/hours_report_top3.snap | 16 ++ internal/handlers/data/aggregator.go | 110 +++++++++ internal/handlers/data/types.go | 17 ++ internal/handlers/metrics.go | 71 +++++- internal/handlers/metrics_test.go | 89 ++++++- internal/handlers/prettify/formatter.go | 196 ++++++++++++++++ internal/handlers/snapshot_test.go | 218 ++++++++++++++++++ lua/pendulum/remote.lua | 2 +- 11 files changed, 756 insertions(+), 15 deletions(-) create mode 100644 internal/handlers/__snapshots__/hours_formatter_direct.snap create mode 100644 internal/handlers/__snapshots__/hours_report_12h.snap create mode 100644 internal/handlers/__snapshots__/hours_report_all.snap create mode 100644 internal/handlers/__snapshots__/hours_report_top3.snap diff --git a/internal/handlers/__snapshots__/hours_formatter_direct.snap b/internal/handlers/__snapshots__/hours_formatter_direct.snap new file mode 100644 index 0000000..7cb419c --- /dev/null +++ b/internal/handlers/__snapshots__/hours_formatter_direct.snap @@ -0,0 +1,16 @@ +--- +title: hours_formatter_direct +test_name: TestHoursFormatterSnapshotDirectly +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Hours Report +**Generated:** +**Log File:** /test/pendulum.csv + +## Times Most Active + Time Overall (Active %) This Week (Active %) Entry Count +1. 10 25.00m (83.33%) 12.00m (80.00%) 3 +2. 14 15.00m (75.00%) 8.00m (80.00%) 1 +3. 9 10.00m (66.67%) 5.00m (62.50%) 2 + diff --git a/internal/handlers/__snapshots__/hours_report_12h.snap b/internal/handlers/__snapshots__/hours_report_12h.snap new file mode 100644 index 0000000..6aebb56 --- /dev/null +++ b/internal/handlers/__snapshots__/hours_report_12h.snap @@ -0,0 +1,18 @@ +--- +title: hours_report_12h +test_name: TestHoursReportSnapshot12h +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Hours Report +**Generated:** +**Log File:** + +## Times Most Active + Time Overall (Active %) This Week (Active %) Entry Count +1. 4PM 19.00m (90.48%) 0s ( 0.00%) 21 +2. 10AM 13.00m (86.67%) 0s ( 0.00%) 15 +3. 2PM 13.00m (86.67%) 0s ( 0.00%) 15 +4. 5PM 11.00m (84.62%) 0s ( 0.00%) 13 +5. 3PM 9.00m (90.00%) 0s ( 0.00%) 10 + diff --git a/internal/handlers/__snapshots__/hours_report_all.snap b/internal/handlers/__snapshots__/hours_report_all.snap new file mode 100644 index 0000000..dcaba6f --- /dev/null +++ b/internal/handlers/__snapshots__/hours_report_all.snap @@ -0,0 +1,18 @@ +--- +title: hours_report_all +test_name: TestHoursReportSnapshot +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Hours Report +**Generated:** +**Log File:** + +## Times Most Active + Time Overall (Active %) This Week (Active %) Entry Count +1. 16 19.00m (90.48%) 0s ( 0.00%) 21 +2. 10 13.00m (86.67%) 0s ( 0.00%) 15 +3. 14 13.00m (86.67%) 0s ( 0.00%) 15 +4. 17 11.00m (84.62%) 0s ( 0.00%) 13 +5. 15 9.00m (90.00%) 0s ( 0.00%) 10 + diff --git a/internal/handlers/__snapshots__/hours_report_top3.snap b/internal/handlers/__snapshots__/hours_report_top3.snap new file mode 100644 index 0000000..b616455 --- /dev/null +++ b/internal/handlers/__snapshots__/hours_report_top3.snap @@ -0,0 +1,16 @@ +--- +title: hours_report_top3 +test_name: TestHoursReportSnapshotTopN +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Hours Report +**Generated:** +**Log File:** + +## Times Most Active + Time Overall (Active %) This Week (Active %) Entry Count +1. 16 19.00m (90.48%) 0s ( 0.00%) 21 +2. 10 13.00m (86.67%) 0s ( 0.00%) 15 +3. 14 13.00m (86.67%) 0s ( 0.00%) 15 + diff --git a/internal/handlers/data/aggregator.go b/internal/handlers/data/aggregator.go index 3316eda..df1e3d9 100644 --- a/internal/handlers/data/aggregator.go +++ b/internal/handlers/data/aggregator.go @@ -280,3 +280,113 @@ func (a *MetricsAggregator) calculateActivePercentages(values map[string]*Pendul } } } + +// AggregatePendulumHours aggregates hourly activity data from CSV records +func (a *MetricsAggregator) AggregatePendulumHours(ctx context.Context, data [][]string) (*HoursResult, error) { + startTime := time.Now() + + if len(data) <= 1 { + return &HoursResult{ + Hours: &PendulumHours{ + ActiveTimestamps: []string{}, + Timestamps: []string{}, + ActiveTimeHours: make(map[int]time.Duration), + ActiveTimeHoursRecent: make(map[int]time.Duration), + TotalTimeHours: make(map[int]time.Duration), + TotalTimeHoursRecent: make(map[int]time.Duration), + }, + Processed: 0, + Duration: time.Since(startTime), + }, nil + } + + hours := &PendulumHours{ + ActiveTimestamps: []string{}, + Timestamps: []string{}, + ActiveTimeHours: make(map[int]time.Duration), + ActiveTimeHoursRecent: make(map[int]time.Duration), + TotalTimeHours: make(map[int]time.Duration), + TotalTimeHoursRecent: make(map[int]time.Duration), + } + + timecol := CSVColumns["time"] + + // Create time range filter for "recent" (last week) + weekFilter, err := NewTimeRangeFilter("week", a.params.TimeZone) + if err != nil { + return nil, err + } + + for i := 1; i < len(data); i++ { + select { + case <-ctx.Done(): + return nil, &MetricsError{ + Type: ErrProcessingTimeout, + Message: "hours aggregation cancelled", + Cause: ctx.Err(), + } + default: + } + + if len(data[i]) <= timecol { + continue + } + + active, err := strconv.ParseBool(data[i][0]) + if err != nil { + log.Printf("Error parsing boolean at row %d, value: %s, error: %v", i, data[i][0], err) + continue + } + + timestampStr := data[i][timecol] + a.updateTotalHours(hours, timestampStr, weekFilter) + + if active { + a.updateActiveHours(hours, timestampStr, weekFilter) + } + } + + return &HoursResult{ + Hours: hours, + Processed: len(data) - 1, + Duration: time.Since(startTime), + }, nil +} + +// updateTotalHours updates total time per hour +func (a *MetricsAggregator) updateTotalHours(hours *PendulumHours, timestampStr string, weekFilter *TimeRangeFilter) { + hours.Timestamps = append(hours.Timestamps, timestampStr) + + t, err := time.Parse("2006-01-02 15:04:05", timestampStr) + if err != nil { + log.Printf("Error parsing timestamp: %s, error: %v", timestampStr, err) + return + } + + tth, _ := TimeDiff(hours.Timestamps, a.params.TimeoutLen, true) + hours.TotalTimeHours[t.Hour()] += tth + + inRange, _ := weekFilter.InRange(timestampStr) + if inRange { + hours.TotalTimeHoursRecent[t.Hour()] += tth + } +} + +// updateActiveHours updates active time per hour +func (a *MetricsAggregator) updateActiveHours(hours *PendulumHours, timestampStr string, weekFilter *TimeRangeFilter) { + hours.ActiveTimestamps = append(hours.ActiveTimestamps, timestampStr) + + t, err := time.Parse("2006-01-02 15:04:05", timestampStr) + if err != nil { + log.Printf("Error parsing timestamp: %s, error: %v", timestampStr, err) + return + } + + ath, _ := TimeDiff(hours.ActiveTimestamps, a.params.TimeoutLen, true) + hours.ActiveTimeHours[t.Hour()] += ath + + inRange, _ := weekFilter.InRange(timestampStr) + if inRange { + hours.ActiveTimeHoursRecent[t.Hour()] += ath + } +} diff --git a/internal/handlers/data/types.go b/internal/handlers/data/types.go index 5f0fb85..b60d6e7 100644 --- a/internal/handlers/data/types.go +++ b/internal/handlers/data/types.go @@ -77,3 +77,20 @@ type ProcessingResult struct { Processed int Duration time.Duration } + +// PendulumHours holds aggregated hourly activity data +type PendulumHours struct { + ActiveTimestamps []string + Timestamps []string + ActiveTimeHours map[int]time.Duration + ActiveTimeHoursRecent map[int]time.Duration + TotalTimeHours map[int]time.Duration + TotalTimeHoursRecent map[int]time.Duration +} + +// HoursResult holds the results of hours processing +type HoursResult struct { + Hours *PendulumHours + Processed int + Duration time.Duration +} diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go index 276c110..e06d4f4 100644 --- a/internal/handlers/metrics.go +++ b/internal/handlers/metrics.go @@ -87,12 +87,77 @@ func GenerateMetricsReport(ctx *glsp.Context, args []any) (string, error) { return output, nil } -// GenerateHourlyReport generates an hourly activity report (placeholder for future implementation) +// GenerateHourlyReport generates an hourly activity report via LSP func GenerateHourlyReport(ctx *glsp.Context, args []any) (string, error) { log.Printf("executing command 'pendulum.generateHourlyReport' with args %v", args) - // TODO: Implement hourly report generation - return "# Hourly Report\n\n*Coming soon...*", nil + // Parse and validate parameters (reuse metrics params) + metricsParams, err := params.ParseMetricsParams(args) + if err != nil { + log.Printf("Error parsing metrics parameters: %v", err) + return "", err + } + + // Use config log file if not provided in params + if metricsParams.LogFile == "" { + cfg := config.Config() + if cfg != nil && cfg.LogFile != "" { + metricsParams.LogFile = cfg.LogFile + } else { + return "", &data.MetricsError{ + Type: data.ErrFileNotFound, + Message: "no log file specified and no default configured", + } + } + } + + // Create processing context with timeout + processingCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Read CSV data + csvReader := data.NewCSVReader(metricsParams.LogFile) + csvData, err := csvReader.ReadAll() + if err != nil { + log.Printf("Error reading CSV file: %v", err) + return "", err + } + + if len(csvData) <= 1 { + return "# No data available\n\nThe log file contains no activity data.", nil + } + + // Create aggregator and process hours + aggregator := data.NewMetricsAggregator(metricsParams) + result, err := aggregator.AggregatePendulumHours(processingCtx, csvData) + if err != nil { + log.Printf("Error aggregating hours: %v", err) + return "", err + } + + // Format results + formatter := prettify.NewMetricsFormatter(metricsParams) + formattedLines := formatter.FormatHours(result.Hours, metricsParams.TopN) + + // Add processing metadata + metadata := []string{ + "", + "---", + "", + "**Processing Summary:**", + "- Rows processed: " + formatNumber(result.Processed), + "- Processing time: " + result.Duration.String(), + } + + formattedLines = append(formattedLines, metadata...) + + // Join all lines into a single string + output := strings.Join(formattedLines, "\n") + + log.Printf("Generated hourly report: %d lines, processed in %v", + len(formattedLines), result.Duration) + + return output, nil } // formatNumber formats a number for display with commas diff --git a/internal/handlers/metrics_test.go b/internal/handlers/metrics_test.go index 6090840..b3f8b25 100644 --- a/internal/handlers/metrics_test.go +++ b/internal/handlers/metrics_test.go @@ -214,22 +214,89 @@ func TestGenerateMetricsReport_EmptyFile(t *testing.T) { } func TestGenerateHourlyReport(t *testing.T) { - // Currently just a placeholder - test that it returns without error - ctx := (*glsp.Context)(nil) - args := []any{ - map[string]any{ - "log_file": "/test.csv", - }, + // Create a temporary CSV file with test data + tmpFile, err := os.CreateTemp("", "pendulum_hours_test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) } + defer os.Remove(tmpFile.Name()) - result, err := GenerateHourlyReport(ctx, args) + // Write test data with different hours + testData := `active,branch,directory,file,filetype,project,time +true,main,/home/user/project,main.go,go,myproject,2024-01-01 10:00:00 +false,main,/home/user/project,main.go,go,myproject,2024-01-01 10:01:00 +true,main,/home/user/project,test.go,go,myproject,2024-01-01 10:02:00 +true,feature,/home/user/project,api.go,go,myproject,2024-01-01 14:00:00 +false,feature,/home/user/docs,README.md,markdown,myproject,2024-01-01 14:01:00 +true,main,/home/user/project,utils.go,go,myproject,2024-01-01 14:02:00` - if err != nil { - t.Fatalf("Unexpected error: %v", err) + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + tests := []struct { + name string + args []any + expectError bool + validateFunc func(string) error + }{ + { + name: "basic hourly report", + args: []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": 5, + }, + }, + expectError: false, + validateFunc: func(result string) error { + if !strings.Contains(result, "# Pendulum Hours Report") { + t.Error("Expected hours report header") + } + if !strings.Contains(result, "Times Most Active") { + t.Error("Expected 'Times Most Active' section") + } + if !strings.Contains(result, "Processing Summary") { + t.Error("Expected processing summary") + } + return nil + }, + }, + { + name: "invalid log file", + args: []any{ + map[string]any{ + "log_file": "/nonexistent/file.csv", + }, + }, + expectError: true, + }, } - if !strings.Contains(result, "Coming soon") { - t.Error("Expected placeholder message for hourly report") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := (*glsp.Context)(nil) + + result, err := GenerateHourlyReport(ctx, tt.args) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if tt.validateFunc != nil { + if err := tt.validateFunc(result); err != nil { + t.Error(err) + } + } + }) } } diff --git a/internal/handlers/prettify/formatter.go b/internal/handlers/prettify/formatter.go index efa33d5..45deb77 100644 --- a/internal/handlers/prettify/formatter.go +++ b/internal/handlers/prettify/formatter.go @@ -211,3 +211,199 @@ func truncateHome(path string) string { return path } + +// hourDuration is a helper struct for sorting hours by duration +type hourDuration struct { + hour int + duration time.Duration +} + +// FormatHours converts PendulumHours into a formatted string report +func (f *MetricsFormatter) FormatHours(hours *data.PendulumHours, topN int) []string { + var lines []string + + // Add header + lines = append(lines, f.generateHoursHeader()) + + // Format the hours report + formatted := f.formatHoursReport(hours, topN) + lines = append(lines, formatted) + + return lines +} + +// generateHoursHeader creates a header for the hours report +func (f *MetricsFormatter) generateHoursHeader() string { + var parts []string + + parts = append(parts, "# Pendulum Hours Report") + parts = append(parts, fmt.Sprintf("**Generated:** %s", time.Now().Format("2006-01-02 15:04:05"))) + parts = append(parts, fmt.Sprintf("**Log File:** %s", truncateHome(f.params.LogFile))) + parts = append(parts, "") + + return strings.Join(parts, "\n") +} + +// formatHoursReport formats the hourly activity data +func (f *MetricsFormatter) formatHoursReport(hours *data.PendulumHours, n int) string { + // Convert hour durations to local timezone + loc, err := time.LoadLocation(f.params.TimeZone) + if err != nil { + loc = time.UTC + } + + hourCountsActive := make(map[int]int) + hourDurationsActive := make(map[int]time.Duration) + hourDurationsTotal := make(map[int]time.Duration) + weekHourDurationsActive := make(map[int]time.Duration) + weekHourDurationsTotal := make(map[int]time.Duration) + + // Count active timestamps per hour + layout := "2006-01-02 15:04:05" + for _, ts := range hours.ActiveTimestamps { + t, err := time.Parse(layout, ts) + if err != nil { + continue + } + hourCountsActive[t.In(loc).Hour()]++ + } + + // Convert hours to local timezone + for k, v := range hours.ActiveTimeHours { + t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) + hourDurationsActive[t.In(loc).Hour()] += v + } + + for k, v := range hours.TotalTimeHours { + t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) + hourDurationsTotal[t.In(loc).Hour()] += v + } + + for k, v := range hours.ActiveTimeHoursRecent { + t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) + weekHourDurationsActive[t.In(loc).Hour()] += v + } + + for k, v := range hours.TotalTimeHoursRecent { + t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) + weekHourDurationsTotal[t.In(loc).Hour()] += v + } + + // Create and sort slice by active duration (with hour as secondary key for determinism) + var hourDurationSlice []hourDuration + for hour, duration := range hourDurationsActive { + hourDurationSlice = append(hourDurationSlice, hourDuration{hour: hour, duration: duration}) + } + + sort.SliceStable(hourDurationSlice, func(a, b int) bool { + if hourDurationSlice[a].duration != hourDurationSlice[b].duration { + return hourDurationSlice[a].duration > hourDurationSlice[b].duration + } + // Secondary sort by hour for deterministic ordering when durations are equal + return hourDurationSlice[a].hour < hourDurationSlice[b].hour + }) + + if n > len(hourDurationSlice) { + n = len(hourDurationSlice) + } + + if n == 0 { + return "No hourly activity data available." + } + + // Calculate column widths for alignment + var overallHoursWidth int + var recentHoursWidth int + + for _, d := range hourDurationsActive { + w := len(f.formatDuration(d)) + if overallHoursWidth < w { + overallHoursWidth = w + } + } + + for _, d := range weekHourDurationsActive { + w := len(f.formatDuration(d)) + if recentHoursWidth < w { + recentHoursWidth = w + } + } + + // Ensure minimum widths + overallHoursWidth = max(overallHoursWidth, 6) + recentHoursWidth = max(recentHoursWidth, 6) + + bulletWidth := len(fmt.Sprintf("%d", n)) + + // Calculate column widths for proper alignment + // Column format: "duration (pct%)" where pct is 5.2f = 6 chars + " (" + ")" = 9 extra + overallColWidth := overallHoursWidth + 9 + recentColWidth := recentHoursWidth + 9 + + // Ensure column widths are at least as wide as headers + overallHeader := "Overall (Active %)" + recentHeader := "This Week (Active %)" + overallColWidth = max(overallColWidth, len(overallHeader)) + recentColWidth = max(recentColWidth, len(recentHeader)) + + // Calculate max entry count width for right-alignment + maxEntryCount := 0 + for i := 0; i < min(n, len(hourDurationSlice)); i++ { + h24 := hourDurationSlice[i].hour + if c := hourCountsActive[h24]; c > maxEntryCount { + maxEntryCount = c + } + } + entryCountWidth := max(len(fmt.Sprintf("%d", maxEntryCount)), len("Entry Count")) + + var out strings.Builder + out.WriteString("## Times Most Active\n") + out.WriteString(fmt.Sprintf("%*s %-5s %*s %*s %*s\n", + bulletWidth, "", + "Time", + overallColWidth, overallHeader, + recentColWidth, recentHeader, + entryCountWidth, "Entry Count")) + + for i := 0; i < n; i++ { + h24 := hourDurationSlice[i].hour + c := hourCountsActive[h24] + dur := hourDurationsActive[h24] + weeklyDur := weekHourDurationsActive[h24] + + h := h24 + var period string + if f.params.TimeFormat == "12h" { + h = h24 % 12 + if h == 0 { + h = 12 + } + period = "AM" + if h24 >= 12 { + period = "PM" + } + } + + var overallPct, recentPct float64 + if total, exists := hourDurationsTotal[h24]; exists && total > 0 { + overallPct = float64(dur) / float64(total) * 100 + } + if total, exists := weekHourDurationsTotal[h24]; exists && total > 0 { + recentPct = float64(weeklyDur) / float64(total) * 100 + } + + // Format the duration + percentage as a single column value (right-aligned) + overallStr := fmt.Sprintf("%*s (%5.2f%%)", overallHoursWidth, f.formatDuration(dur), overallPct) + recentStr := fmt.Sprintf("%*s (%5.2f%%)", recentHoursWidth, f.formatDuration(weeklyDur), recentPct) + + out.WriteString(fmt.Sprintf("%*d. %2d%-2s %*s %*s %*d\n", + bulletWidth, i+1, + h, period, + overallColWidth, overallStr, + recentColWidth, recentStr, + entryCountWidth, c, + )) + } + + return out.String() +} diff --git a/internal/handlers/snapshot_test.go b/internal/handlers/snapshot_test.go index bd148ce..fa7f256 100644 --- a/internal/handlers/snapshot_test.go +++ b/internal/handlers/snapshot_test.go @@ -266,3 +266,221 @@ func TestFormatterSnapshotDirectly(t *testing.T) { shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), ) } + +// TestHoursReportSnapshot tests the full hours report output using snapshot testing +func TestHoursReportSnapshot(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params for the report + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 5, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, // 3 minutes - entries in test data are 1 min apart + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate hours + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumHours(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate hours: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatHours(result.Hours, params.TopN) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers for dynamic content + shutter.SnapString(t, "hours_report_all", report, + // Scrub the generated timestamp (format: 2006-01-02 15:04:05) + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + // Scrub the log file path which varies by environment + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestHoursReportSnapshot12h tests hours report with 12-hour time format +func TestHoursReportSnapshot12h(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params with 12h format + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 5, + TimeRange: "all", + TimeFormat: "12h", + TimeZone: "UTC", + TimeoutLen: 180, + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate hours + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumHours(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate hours: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatHours(result.Hours, params.TopN) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers + shutter.SnapString(t, "hours_report_12h", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestHoursReportSnapshotTopN tests hours report with different TopN values +func TestHoursReportSnapshotTopN(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params with TopN = 3 + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 3, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate hours + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumHours(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate hours: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatHours(result.Hours, params.TopN) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers + shutter.SnapString(t, "hours_report_top3", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestHoursFormatterSnapshotDirectly tests the hours formatter output directly without full pipeline +func TestHoursFormatterSnapshotDirectly(t *testing.T) { + // Create synthetic hours data directly + hours := &data.PendulumHours{ + ActiveTimestamps: []string{ + "2024-06-15 09:00:00", + "2024-06-15 09:01:00", + "2024-06-15 10:00:00", + "2024-06-15 10:01:00", + "2024-06-15 10:02:00", + "2024-06-15 14:00:00", + }, + Timestamps: []string{ + "2024-06-15 09:00:00", + "2024-06-15 09:01:00", + "2024-06-15 09:02:00", + "2024-06-15 10:00:00", + "2024-06-15 10:01:00", + "2024-06-15 10:02:00", + "2024-06-15 10:03:00", + "2024-06-15 14:00:00", + "2024-06-15 14:01:00", + }, + ActiveTimeHours: map[int]time.Duration{ + 9: 10 * time.Minute, + 10: 25 * time.Minute, + 14: 15 * time.Minute, + }, + ActiveTimeHoursRecent: map[int]time.Duration{ + 9: 5 * time.Minute, + 10: 12 * time.Minute, + 14: 8 * time.Minute, + }, + TotalTimeHours: map[int]time.Duration{ + 9: 15 * time.Minute, + 10: 30 * time.Minute, + 14: 20 * time.Minute, + }, + TotalTimeHoursRecent: map[int]time.Duration{ + 9: 8 * time.Minute, + 10: 15 * time.Minute, + 14: 10 * time.Minute, + }, + } + + params := &data.MetricsParams{ + LogFile: "/test/pendulum.csv", + TopN: 5, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + } + + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatHours(hours, params.TopN) + + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + shutter.SnapString(t, "hours_formatter_direct", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + ) +} diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index f71ebcd..1129345 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -143,7 +143,7 @@ local function setup_pendulum_commands(lsp_client) -- Send request to LSP for hours report lsp_client:request("workspace/executeCommand", { - command = "pendulum.generateHoursReport", + command = "pendulum.generateHourlyReport", arguments = { options }, }, handle_lsp_response) end, { nargs = 0 }) From 6513568f5975bba03e42f080f764b1f430ce4918 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sat, 17 Jan 2026 17:09:54 -0500 Subject: [PATCH 18/20] fix: fix missing exclude functionality in v2 --- .gitignore | 6 +- internal/handlers/data/utils.go | 5 +- internal/handlers/metrics.go | 8 ++ lua/pendulum/handlers.lua | 81 ++++++++++---- lua/pendulum/remote.lua | 180 +++++++++++++++++++++++++++++++- 5 files changed, 249 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 7f6b4f0..e7cca9d 100644 --- a/.gitignore +++ b/.gitignore @@ -70,9 +70,5 @@ luac.out ## Extras remote/pendulum-nvim pendulum-server +bin/ .luarc.json - - -# Added by cargo - -/target diff --git a/internal/handlers/data/utils.go b/internal/handlers/data/utils.go index 2760c95..da1ec53 100644 --- a/internal/handlers/data/utils.go +++ b/internal/handlers/data/utils.go @@ -170,12 +170,15 @@ func NewTimeRangeFilter(rangeType, timeZone string) (*TimeRangeFilter, error) { } // InRange checks if a timestamp string falls within the pre-computed range +// Note: timestamps in the CSV are stored in UTC, so we parse them as UTC +// and compare against the range boundaries (which are also in UTC internally) func (f *TimeRangeFilter) InRange(timestampStr string) (bool, error) { if f.isAll { return true, nil } - timestamp, err := time.ParseInLocation(f.layout, timestampStr, f.loc) + // Timestamps in CSV are always in UTC + timestamp, err := time.Parse(f.layout, timestampStr) if err != nil { return false, err } diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go index e06d4f4..63f71be 100644 --- a/internal/handlers/metrics.go +++ b/internal/handlers/metrics.go @@ -25,6 +25,14 @@ func GenerateMetricsReport(ctx *glsp.Context, args []any) (string, error) { return "", err } + // Log parsed exclusion settings for debugging + if len(metricsParams.ReportExcludes) > 0 { + log.Printf("ReportExcludes: %v", metricsParams.ReportExcludes) + } + if len(metricsParams.ReportSectionExcludes) > 0 { + log.Printf("ReportSectionExcludes: %v", metricsParams.ReportSectionExcludes) + } + // Use config log file if not provided in params if metricsParams.LogFile == "" { cfg := config.Config() diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 41e59e3..1dd9ed8 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -3,6 +3,27 @@ local M = {} local lsp_client = nil local lsp_ready = false local message_queue = {} +local stored_opts = nil + +-- Get the plugin installation path +local function get_plugin_path() + -- Extract path from this file's location: .../pendulum-nvim/lua/pendulum/handlers.lua + return debug.getinfo(1).source:sub(2):match("(.*/)lua/pendulum/") +end + +-- Get the default binary path based on OS +local function get_default_bin_path() + local path = get_plugin_path() + if not path then + return nil + end + + local uname = vim.loop.os_uname().sysname + local path_separator = (uname == "Windows_NT") and "\\" or "/" + local bin_name = (uname == "Windows_NT") and "pendulum-lsp.exe" or "pendulum-lsp" + + return path .. "bin" .. path_separator .. bin_name +end local function flush_queue() if not lsp_ready or not lsp_client or lsp_client:is_stopped() then @@ -79,21 +100,27 @@ local function log_full_activity(filepath) end local function init_lsp_client(opts) - if lsp_client then + if lsp_client and not lsp_client:is_stopped() then return lsp_client end + -- Reset state + lsp_client = nil + lsp_ready = false + + if not opts.lsp_binary then + -- Binary path not set, remote.lua will handle building + return nil + end + local stat = vim.loop.fs_stat(opts.lsp_binary) if not stat then - vim.notify( - "Pendulum LSP binary not found: " .. opts.lsp_binary, - vim.log.levels.ERROR - ) + -- Binary doesn't exist, remote.lua will handle building return nil end - if not vim.fn.executable(opts.lsp_binary) then + if vim.fn.executable(opts.lsp_binary) ~= 1 then vim.notify( "Pendulum LSP binary is not executable: " .. opts.lsp_binary, vim.log.levels.ERROR @@ -130,14 +157,16 @@ local function init_lsp_client(opts) on_exit = function(code, signal, _) lsp_client = nil lsp_ready = false - vim.notify( - string.format( - "Pendulum LSP server exited with code: %s, signal: %s", - tostring(code), - tostring(signal) - ), - vim.log.levels.ERROR - ) + if code ~= 0 then + vim.notify( + string.format( + "Pendulum LSP server exited with code: %s, signal: %s", + tostring(code), + tostring(signal) + ), + vim.log.levels.WARN + ) + end end, }) @@ -147,11 +176,6 @@ local function init_lsp_client(opts) "Pendulum LSP client started with ID: " .. client_id, vim.log.levels.INFO ) - else - vim.notify( - "Failed to start Pendulum LSP server: " .. opts.lsp_binary, - vim.log.levels.ERROR - ) end return lsp_client @@ -161,12 +185,27 @@ function M.get_lsp_client() return lsp_client end +-- Reinitialize LSP client (called after binary is built) +function M.reinit_lsp() + if stored_opts then + return init_lsp_client(stored_opts) + end + return nil +end + function M.setup(opts) opts = opts or {} - opts.lsp_binary = opts.lsp_binary or "pendulum-lsp" opts.timeout_len = opts.timeout_len or 5 opts.timer_len = opts.timer_len or 1 + -- Determine binary path - use provided path or default to plugin bin directory + if not opts.lsp_binary then + opts.lsp_binary = get_default_bin_path() + end + + -- Store opts for potential reinit + stored_opts = opts + if not opts.log_file then vim.notify( "Pendulum: log_file is required in setup options", @@ -216,7 +255,7 @@ function M.setup(opts) end, }) - -- Initialize LSP client immediately + -- Initialize LSP client (will silently fail if binary doesn't exist) init_lsp_client(opts) end diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index 1129345..92c2a04 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -1,6 +1,105 @@ local M = {} local options = {} +local plugin_path = nil +local bin_path = nil + +-- Get the plugin installation path +local function get_plugin_path() + if plugin_path then + return plugin_path + end + -- Extract path from this file's location: .../pendulum-nvim/lua/pendulum/remote.lua + plugin_path = debug.getinfo(1).source:sub(2):match("(.*/)lua/pendulum/") + return plugin_path +end + +-- Get the binary path based on OS +local function get_bin_path() + if bin_path then + return bin_path + end + + local path = get_plugin_path() + if not path then + return nil + end + + local uname = vim.loop.os_uname().sysname + local path_separator = (uname == "Windows_NT") and "\\" or "/" + local bin_name = (uname == "Windows_NT") and "pendulum-lsp.exe" + or "pendulum-lsp" + + bin_path = path .. "bin" .. path_separator .. bin_name + return bin_path +end + +-- Check if the binary exists +local function binary_exists() + local path = get_bin_path() + if not path then + return false + end + + local stat = vim.loop.fs_stat(path) + return stat ~= nil +end + +-- Build the LSP binary +local function build_binary(callback) + local path = get_plugin_path() + if not path then + vim.notify("Could not determine plugin path", vim.log.levels.ERROR) + if callback then + callback(false) + end + return + end + + vim.notify("Building Pendulum LSP binary with Go...", vim.log.levels.INFO) + + local target_bin = get_bin_path() + local build_cmd = string.format( + "cd %s && go build -o %s .", + vim.fn.shellescape(path), + vim.fn.shellescape(target_bin) + ) + + vim.fn.jobstart(build_cmd, { + on_exit = function(_, code, _) + if code == 0 then + vim.schedule(function() + vim.notify( + "Pendulum LSP binary compiled successfully.", + vim.log.levels.INFO + ) + if callback then + callback(true) + end + end) + else + vim.schedule(function() + vim.notify( + "Failed to compile Pendulum LSP binary. Make sure Go is installed.", + vim.log.levels.ERROR + ) + if callback then + callback(false) + end + end) + end + end, + on_stderr = function(_, data, _) + for _, line in ipairs(data) do + if line ~= "" then + vim.schedule(function() + vim.notify("Build: " .. line, vim.log.levels.WARN) + end) + end + end + end, + }) +end -- Function to create a buffer with the content received from the LSP local function create_buffer(content, filetype) @@ -97,7 +196,7 @@ local function setup_pendulum_commands(lsp_client) vim.api.nvim_create_user_command("Pendulum", function(args) if not lsp_client or lsp_client:is_stopped() then vim.notify( - "Pendulum LSP client not available", + "Pendulum LSP client not available. Try :PendulumRebuild", vim.log.levels.ERROR ) return @@ -118,6 +217,7 @@ local function setup_pendulum_commands(lsp_client) }, handle_lsp_response) end, { nargs = "?", + force = true, complete = function(arg_lead, cmd_line, cursor_pos) -- Filter options based on what user has typed local matches = {} @@ -133,7 +233,7 @@ local function setup_pendulum_commands(lsp_client) vim.api.nvim_create_user_command("PendulumHours", function() if not lsp_client or lsp_client:is_stopped() then vim.notify( - "Pendulum LSP client not available", + "Pendulum LSP client not available. Try :PendulumRebuild", vim.log.levels.ERROR ) return @@ -146,13 +246,85 @@ local function setup_pendulum_commands(lsp_client) command = "pendulum.generateHourlyReport", arguments = { options }, }, handle_lsp_response) - end, { nargs = 0 }) + end, { nargs = 0, force = true }) +end + +-- Setup the PendulumRebuild command (always available) +local function setup_rebuild_command() + vim.api.nvim_create_user_command("PendulumRebuild", function() + -- Stop existing LSP client if running + local handlers = require("pendulum.handlers") + local client = handlers.get_lsp_client() + if client and not client:is_stopped() then + vim.notify( + "Stopping Pendulum LSP before rebuild...", + vim.log.levels.INFO + ) + client:stop() + end + + build_binary(function(success) + if success then + -- Reinitialize the LSP client + vim.defer_fn(function() + local new_client = handlers.reinit_lsp() + if new_client then + setup_pendulum_commands(new_client) + vim.notify( + "Pendulum LSP reinitialized.", + vim.log.levels.INFO + ) + else + vim.notify( + "Pendulum LSP binary built. Restart Neovim to use it.", + vim.log.levels.INFO + ) + end + end, 500) + end + end) + end, { nargs = 0, force = true }) end -- Report generation setup using LSP function M.setup(opts) options = opts + -- Determine binary path - use provided path or default to plugin bin directory + if not opts.lsp_binary then + opts.lsp_binary = get_bin_path() + end + + -- Always set up the PendulumRebuild command + setup_rebuild_command() + + -- Check if binary exists, build if missing + if not binary_exists() then + vim.notify( + "Pendulum LSP binary not found, attempting to build...", + vim.log.levels.INFO + ) + build_binary(function(success) + if success then + -- Reinitialize the LSP client after build + vim.defer_fn(function() + local handlers = require("pendulum.handlers") + local new_client = handlers.reinit_lsp() + if new_client then + setup_pendulum_commands(new_client) + end + end, 500) + end + end) + return + end + + -- Binary exists, initialize commands + M.initialize_lsp_commands() +end + +-- Initialize LSP commands (called after binary is available) +function M.initialize_lsp_commands() -- Get LSP client from handlers module local handlers = require("pendulum.handlers") local lsp_client = handlers.get_lsp_client() @@ -168,7 +340,7 @@ function M.setup(opts) setup_pendulum_commands(client) else vim.notify( - "Unable to get Pendulum LSP client", + "Pendulum LSP client not available. Try :PendulumRebuild", vim.log.levels.WARN ) end From 45b616f3b6387cff136561fa2417d5b36bc9d8cc Mon Sep 17 00:00:00 2001 From: pdewey Date: Thu, 22 Jan 2026 22:18:23 -0500 Subject: [PATCH 19/20] docs: migration docs --- BACKLOG.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 BACKLOG.md diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..dd51564 --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,7 @@ +# FIX + +- Logs aren't sent when existing a buffer (base this behavior off of v1) + +# TODO + +- Document v1/v2 differences, how to use v1 if want to stay From a2af32910db552fe7f88b9b5b63a2e95f5be456c Mon Sep 17 00:00:00 2001 From: pdewey Date: Fri, 23 Jan 2026 08:07:43 -0500 Subject: [PATCH 20/20] fix: vim leave event --- BACKLOG.md | 3 +- lua/pendulum/handlers.lua | 74 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index dd51564..f0f936f 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1,7 +1,8 @@ # FIX -- Logs aren't sent when existing a buffer (base this behavior off of v1) +- Logs twice when switching to new buffer (new buffer is logged twice) # TODO - Document v1/v2 differences, how to use v1 if want to stay + - v1 will be tagged in git diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 1dd9ed8..b5f1da0 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -75,17 +75,72 @@ local function send_to_lsp(command, args) end, 0) end +-- Synchronous version of send_to_lsp for critical operations like VimLeave +local function send_to_lsp_sync(command, args, timeout_ms) + timeout_ms = timeout_ms or 1000 + + if not lsp_ready or not lsp_client or lsp_client:is_stopped() then + return false + end + + local done = false + local success = false + + lsp_client:request("workspace/executeCommand", { + command = command, + arguments = args and { args } or {}, + }, function(err, _) + if err then + vim.notify( + "Failed to execute " + .. command + .. ": " + .. tostring(err.message or err), + vim.log.levels.ERROR + ) + success = false + else + success = true + end + done = true + end, 0) + + -- Wait for the request to complete or timeout + local wait_result = vim.wait(timeout_ms, function() + return done + end, 10) + + return wait_result and success +end + local function ping_activity() + -- Skip special buffer types (terminal, quickfix, help, etc.) + local buftype = vim.bo.buftype + if buftype ~= "" then + return + end + send_to_lsp("pendulum.activityPing") end local function log_full_activity(filepath) + -- Skip special buffer types (terminal, quickfix, help, etc.) + local buftype = vim.bo.buftype + if buftype ~= "" then + return + end + -- Use provided filepath or get current buffer's path local file = filepath or vim.fn.expand("%:p") if file == "" then return end + -- Skip terminal buffers and other non-file buffers + if file:match("^term://") or file:match("^fugitive://") or file:match("^oil://") then + return + end + local ft = vim.bo.filetype ~= "" and vim.bo.filetype or "unknown_filetype" local data = { @@ -249,8 +304,23 @@ function M.setup(opts) group = "Pendulum", callback = function() if lsp_client and not lsp_client:is_stopped() then - log_full_activity() - send_to_lsp("pendulum.endSession") + -- Use synchronous logging for VimLeave to ensure logs are written before exit + -- Skip special buffer types + local buftype = vim.bo.buftype + local file = vim.fn.expand("%:p") + if file ~= "" and buftype == "" and not file:match("^term://") and not file:match("^fugitive://") and not file:match("^oil://") then + local ft = vim.bo.filetype ~= "" and vim.bo.filetype + or "unknown_filetype" + local data = { + time = os.date("!%Y-%m-%d %H:%M:%S"), + active = true, + file = file, + filetype = ft, + cwd = vim.loop.cwd(), + } + send_to_lsp_sync("pendulum.logActivity", data, 1000) + end + send_to_lsp_sync("pendulum.endSession", nil, 500) end end, })