diff --git a/AGENTS.md b/AGENTS.md index 470a097..387a809 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,8 @@ Current state: **v0.1** — parser and CLI complete, no UI yet. crates/ ├── org-ast/ # AST types (Document, Section, Element, etc.) ├── org-parser/ # Tree-sitter parser, AST conversion -├── orkmode-core/ # High-level API, C FFI -└── ork-cli/ # CLI tool (bin name: ork) +├── ork-server/ # 9P server exposing org files (bin: ork-server) +└── ork-cli/ # CLI tool, a 9P client (bin name: ork) ``` ## Working with the Code @@ -64,11 +64,11 @@ Use `-j/--json` for machine-readable output. Use `-n/--dry-run` for mutations. - `Document` → `Section`* → `Element`* / `Section`* - Inline objects (bold, links) are partially implemented -### FFI +### 9P server -- C-compatible FFI in `orkmode-core/src/ffi.rs` -- Functions prefixed `ork_*` -- Caller owns returned strings (free with `ork_string_free`) +- `ork-server` exposes org files as a 9P namespace (docs, sections, fields, agenda, query) +- Structural ops via the `ctl` file: rm, move, promote, demote, refile, archive, lint +- `ork` is a pure 9P client; all org logic lives in the server ## Conventions @@ -100,16 +100,16 @@ Use `-j/--json` for machine-readable output. Use `-n/--dry-run` for mutations. 3. **Timestamp text** — displays "set" placeholder, not actual date 4. **No incremental parsing** — full reparse on every call -## Next Steps (v0.2) +## Next Steps (v0.3) -Priority: Qt/QML read-only document viewer. +Priority: Qt/QML read-only document viewer, over 9P. Tasks: 1. Set up Qt6/QML project in `ui/` -2. Link orkmode-core via C FFI -3. Implement document model (QAbstractItemModel) +2. Connect to `ork-server` over 9P (no FFI) +3. Implement document model (QAbstractItemModel) from the 9P namespace 4. Basic headline rendering with folding -5. Syntax highlighting +5. Structural styling (keywords, priorities, tags, timestamps) ## Testing Changes diff --git a/Cargo.lock b/Cargo.lock index b319a62..956f25e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,12 +346,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - [[package]] name = "js-sys" version = "0.3.106" @@ -571,18 +565,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "orkmode-core" -version = "0.1.0" -dependencies = [ - "org-ast", - "org-parser", - "pretty_assertions", - "serde", - "serde_json", - "thiserror", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -754,19 +736,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -1197,9 +1166,3 @@ name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 96e8392..21a32ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ resolver = "2" members = [ "crates/org-parser", "crates/org-ast", - "crates/orkmode-core", "crates/ork-cli", "crates/ork-server", ] diff --git a/README.md b/README.md index 667b37c..7af5e63 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,17 @@ orkmode/ │ │ ├── convert.rs # Tree-sitter → AST conversion │ │ ├── ts.rs # Tree-sitter language bindings │ │ └── error.rs # Error types -│ ├── orkmode-core/ # Core library with FFI +│ ├── ork-cli/ # Command-line interface (9P client) │ │ └── src/ -│ │ ├── lib.rs # High-level API -│ │ └── ffi.rs # C FFI for Qt integration -│ └── ork-cli/ # Command-line interface +│ │ ├── main.rs # CLI commands (base + friendly verbs) +│ │ └── client.rs # 9P2000 client +│ └── ork-server/ # 9P server exposing org files │ └── src/ -│ ├── main.rs # CLI entry point -│ └── commands.rs # Command implementations +│ ├── bin/ork-server.rs # Server entry point +│ ├── state.rs # Document state, section ops, ctl verbs +│ ├── namespace.rs # 9P namespace (docs, sections, agenda, query) +│ ├── p9.rs # 9P protocol handling +│ └── virtfs.rs # Virtual filesystem tree └── ui/ # (future) Qt/QML UI ``` @@ -189,31 +192,28 @@ for section in doc.all_sections() { } ``` -### C/C++ FFI +### 9P server & client -```c -#include "orkmode.h" +Serve a directory of org files over 9P, then act on it with `ork`: -// Parse a document -OrkDocument* doc = ork_parse("* Hello World"); -if (doc) { - // Get as JSON - char* json = ork_document_to_json(doc); - printf("%s\n", json); - ork_string_free(json); - - // Get title - char* title = ork_document_title(doc); - if (title) { - printf("Title: %s\n", title); - ork_string_free(title); - } - - // Cleanup - ork_document_free(doc); -} +```sh +# Start the server (unix socket or tcp) +ork-server -a unix:///tmp/ork.sock -d ~/org & + +# Point the client at it (or pass -a on each call) +export ORK_ADDR=unix:///tmp/ork.sock + +ork docs # list documents +ork tree work # outline of work.org +ork todos # TODO items across all docs +ork set work my-task keyword DONE +ork refile work my-task archive --parent done-items +ork query 'tag:work+todo:TODO' ``` +Any 9P client works too (e.g. plan9port `9p`); the server owns all org +logic and reads/writes the real `.org` files. + ## Architecture ### Parser Design diff --git a/crates/orkmode-core/Cargo.toml b/crates/orkmode-core/Cargo.toml deleted file mode 100644 index 59e5efe..0000000 --- a/crates/orkmode-core/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "orkmode-core" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -description = "Core library for orkmode - org-mode implementation" - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -org-ast = { path = "../org-ast" } -org-parser = { path = "../org-parser" } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -pretty_assertions = { workspace = true } diff --git a/crates/orkmode-core/src/ffi.rs b/crates/orkmode-core/src/ffi.rs deleted file mode 100644 index b144cc2..0000000 --- a/crates/orkmode-core/src/ffi.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! FFI bindings for C/C++ integration. -//! -//! This module provides a C-compatible API for the orkmode parser, -//! enabling integration with Qt/C++ applications. - -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; -use std::ptr; - -use crate::{parse, parse_to_json_pretty}; - -/// Opaque handle to a parsed document. -pub struct OrkDocument { - doc: org_ast::Document, - _source: String, // Keep source alive for spans -} - -/// Parse an org document from a C string. -/// -/// Returns a handle to the parsed document, or null on error. -/// The caller must free the document with `ork_document_free`. -/// -/// # Safety -/// - `source` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn ork_parse(source: *const c_char) -> *mut OrkDocument { - if source.is_null() { - return ptr::null_mut(); - } - - let source_str = match CStr::from_ptr(source).to_str() { - Ok(s) => s.to_string(), - Err(_) => return ptr::null_mut(), - }; - - match parse(&source_str) { - Ok(doc) => Box::into_raw(Box::new(OrkDocument { - doc, - _source: source_str, - })), - Err(_) => ptr::null_mut(), - } -} - -/// Free a parsed document. -/// -/// # Safety -/// - `doc` must be a valid pointer returned by `ork_parse`, or null. -/// - After this call, `doc` must not be used. -#[no_mangle] -pub unsafe extern "C" fn ork_document_free(doc: *mut OrkDocument) { - if !doc.is_null() { - drop(Box::from_raw(doc)); - } -} - -/// Get the document as JSON. -/// -/// Returns a newly allocated string that must be freed with `ork_string_free`. -/// Returns null on error. -/// -/// # Safety -/// - `doc` must be a valid pointer returned by `ork_parse`. -#[no_mangle] -pub unsafe extern "C" fn ork_document_to_json(doc: *const OrkDocument) -> *mut c_char { - if doc.is_null() { - return ptr::null_mut(); - } - - let doc = &(*doc).doc; - match serde_json::to_string_pretty(doc) { - Ok(json) => match CString::new(json) { - Ok(cstr) => cstr.into_raw(), - Err(_) => ptr::null_mut(), - }, - Err(_) => ptr::null_mut(), - } -} - -/// Parse an org document and return JSON directly. -/// -/// This is a convenience function that combines parsing and JSON serialization. -/// Returns a newly allocated string that must be freed with `ork_string_free`. -/// Returns null on error. -/// -/// # Safety -/// - `source` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn ork_parse_to_json(source: *const c_char) -> *mut c_char { - if source.is_null() { - return ptr::null_mut(); - } - - let source_str = match CStr::from_ptr(source).to_str() { - Ok(s) => s, - Err(_) => return ptr::null_mut(), - }; - - match parse_to_json_pretty(source_str) { - Ok(json) => match CString::new(json) { - Ok(cstr) => cstr.into_raw(), - Err(_) => ptr::null_mut(), - }, - Err(_) => ptr::null_mut(), - } -} - -/// Free a string returned by orkmode functions. -/// -/// # Safety -/// - `s` must be a valid pointer returned by an orkmode function, or null. -/// - After this call, `s` must not be used. -#[no_mangle] -pub unsafe extern "C" fn ork_string_free(s: *mut c_char) { - if !s.is_null() { - drop(CString::from_raw(s)); - } -} - -/// Get the document title. -/// -/// Returns a newly allocated string that must be freed with `ork_string_free`. -/// Returns null if no title is set. -/// -/// # Safety -/// - `doc` must be a valid pointer returned by `ork_parse`. -#[no_mangle] -pub unsafe extern "C" fn ork_document_title(doc: *const OrkDocument) -> *mut c_char { - if doc.is_null() { - return ptr::null_mut(); - } - - match (*doc).doc.title() { - Some(title) => match CString::new(title) { - Ok(cstr) => cstr.into_raw(), - Err(_) => ptr::null_mut(), - }, - None => ptr::null_mut(), - } -} - -/// Get the number of top-level sections. -/// -/// # Safety -/// - `doc` must be a valid pointer returned by `ork_parse`. -#[no_mangle] -pub unsafe extern "C" fn ork_document_section_count(doc: *const OrkDocument) -> usize { - if doc.is_null() { - return 0; - } - (*doc).doc.sections.len() -} - -/// Get the total number of sections (including nested). -/// -/// # Safety -/// - `doc` must be a valid pointer returned by `ork_parse`. -#[no_mangle] -pub unsafe extern "C" fn ork_document_total_section_count(doc: *const OrkDocument) -> usize { - if doc.is_null() { - return 0; - } - (*doc).doc.all_sections().count() -} - -/// Get the number of TODO items. -/// -/// # Safety -/// - `doc` must be a valid pointer returned by `ork_parse`. -#[no_mangle] -pub unsafe extern "C" fn ork_document_todo_count(doc: *const OrkDocument) -> usize { - if doc.is_null() { - return 0; - } - (*doc).doc.todos().count() -} - -// Version info -/// Get the library version as a string. -#[no_mangle] -pub extern "C" fn ork_version() -> *const c_char { - // This is a static string, so it doesn't need to be freed. - static VERSION: &[u8] = b"0.1.0\0"; - VERSION.as_ptr() as *const c_char -} diff --git a/crates/orkmode-core/src/lib.rs b/crates/orkmode-core/src/lib.rs deleted file mode 100644 index c9d895e..0000000 --- a/crates/orkmode-core/src/lib.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Orkmode Core Library -//! -//! This crate provides the core functionality for orkmode, including: -//! - Document parsing and AST manipulation -//! - FFI bindings for C++/Qt integration -//! - Serialization for data exchange - -pub use org_ast; -pub use org_parser; - -pub mod ffi; - -use org_ast::Document; -use org_parser::{Parser, ParseError}; - -/// Parse an org document from a string. -pub fn parse(source: &str) -> Result { - let mut parser = Parser::new(); - parser.parse(source) -} - -/// Parse an org document and return it as JSON. -pub fn parse_to_json(source: &str) -> Result { - let doc = parse(source).map_err(|e| e.to_string())?; - serde_json::to_string(&doc).map_err(|e| e.to_string()) -} - -/// Parse an org document and return it as pretty-printed JSON. -pub fn parse_to_json_pretty(source: &str) -> Result { - let doc = parse(source).map_err(|e| e.to_string())?; - serde_json::to_string_pretty(&doc).map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_simple() { - let doc = parse("* Hello World\nSome content").unwrap(); - assert_eq!(doc.sections.len(), 1); - assert_eq!(doc.sections[0].headline.title_text(), "Hello World"); - } - - #[test] - fn test_parse_to_json() { - let json = parse_to_json("* Test").unwrap(); - assert!(json.contains("\"sections\"")); - assert!(json.contains("Test")); - } - - #[test] - fn test_complex_document() { - let source = r#"#+TITLE: Test Document -#+TODO: TODO WAITING | DONE CANCELLED - -* TODO [#A] First Task :work:urgent: -DEADLINE: <2025-01-15 Wed 14:00> -:PROPERTIES: -:CUSTOM_ID: task-1 -:END: - -Some description here. - -** Subtask -- [ ] Item 1 -- [X] Item 2 - -* DONE Second Task -CLOSED: [2025-01-14 Tue 10:00] - -#+BEGIN_SRC python -def hello(): - print("Hello, World!") -#+END_SRC -"#; - - let doc = parse(source).unwrap(); - assert_eq!(doc.settings.title, Some("Test Document".to_string())); - assert_eq!(doc.sections.len(), 2); - - // First section - let first = &doc.sections[0]; - assert!(first.is_todo()); - assert!(first.has_tag("work")); - assert!(first.has_tag("urgent")); - assert_eq!(first.headline.priority, Some(org_ast::Priority::A)); - - // Check children - assert_eq!(first.children.len(), 1); - - // Second section - let second = &doc.sections[1]; - assert!(second.is_done()); - } -}