diff --git a/crates/core/src/compat/config.rs b/crates/core/src/compat/config.rs new file mode 100644 index 000000000..7d2fd45d9 --- /dev/null +++ b/crates/core/src/compat/config.rs @@ -0,0 +1,5 @@ +use serde_json::Value; + +pub struct UserConfig { + internal: Value, +} diff --git a/crates/core/src/compat/mod.rs b/crates/core/src/compat/mod.rs new file mode 100644 index 000000000..ef68c3694 --- /dev/null +++ b/crates/core/src/compat/mod.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/crates/core/src/compat/plugin.rs b/crates/core/src/compat/plugin.rs new file mode 100644 index 000000000..cec1366dc --- /dev/null +++ b/crates/core/src/compat/plugin.rs @@ -0,0 +1,7 @@ +pub struct Plugin { + /// An internal identifier that the server uses to identify the plugin. + /// This identifier is guaranteed to be unique for the lifetime of the + /// plugin server but is not guaranteed to be unique across multiple + /// invocations of the server. + handle: u64 +} diff --git a/crates/plugin-example/Cargo.toml b/crates/plugin-example/Cargo.toml new file mode 100644 index 000000000..ddc45a584 --- /dev/null +++ b/crates/plugin-example/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tailwindcss-plugin-example" +version = "0.1.0" +edition = "2021" + +[dependencies] +lsp-server = "0.7.6" +serde = "1.0.209" +serde_json = "1.0.127" +tracing = "0.1.40" +tailwindcss-core = { path = "../core" } +crossbeam-channel = "0.5.13" +log = "0.4.22" +tokio = { version = "1.39.3", features = ["full"] } +mio = "1.0.2" diff --git a/crates/plugin-example/node/package-lock.json b/crates/plugin-example/node/package-lock.json new file mode 100644 index 000000000..b80ce0abd --- /dev/null +++ b/crates/plugin-example/node/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver": "^9.0.1" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" + } + } +} diff --git a/crates/plugin-example/node/package.json b/crates/plugin-example/node/package.json new file mode 100644 index 000000000..951b95664 --- /dev/null +++ b/crates/plugin-example/node/package.json @@ -0,0 +1,15 @@ +{ + "name": "node", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver": "^9.0.1" + } +} diff --git a/crates/plugin-example/node/src/client.ts b/crates/plugin-example/node/src/client.ts new file mode 100644 index 000000000..8fd3a9d1c --- /dev/null +++ b/crates/plugin-example/node/src/client.ts @@ -0,0 +1,14 @@ +import * as net from 'node:net' +import * as rpc from 'vscode-jsonrpc/node' + +let client = net.connect(12345) + +// Use stdin and stdout for communication: +let connection = rpc.createMessageConnection(client, client) + +connection.onNotification('loaded', () => console.log('server loaded')) +connection.onNotification('ping', () => console.log('ping')) +connection.onNotification('@/plugins/loaded', () => console.log('plugins loaded')) + +connection.listen() +connection.sendRequest('@/plugins/load', 'foo') diff --git a/crates/plugin-example/node/src/plugin-api.ts b/crates/plugin-example/node/src/plugin-api.ts new file mode 100644 index 000000000..8740bf828 --- /dev/null +++ b/crates/plugin-example/node/src/plugin-api.ts @@ -0,0 +1,127 @@ +export type PluginFn = (api: PluginAPI) => void +export type PluginWithConfig = { handler: PluginFn } +export type PluginWithOptions = { + (options?: T): PluginWithConfig + __isOptionsFunction: true +} + +export type Plugin = PluginFn | PluginWithConfig | PluginWithOptions +export type CssInJs = { [key: string]: string | CssInJs | CssInJs[] } + +export type NamedUtilityValue = { + kind: 'named' + + /** + * bg-red-500 + * ^^^^^^^ + * + * w-1/2 + * ^ + */ + value: string + + /** + * w-1/2 + * ^^^ + */ + fraction: string | null +} + +export type PluginAPI = { + addBase(base: CssInJs): void + addVariant(name: string, variant: string | string[] | CssInJs): void + + addUtilities( + utilities: Record | Record[], + options?: {}, + ): void + + matchUtilities( + utilities: Record< + string, + (value: string, extra: { modifier: string | null }) => CssInJs | CssInJs[] + >, + options?: Partial<{ + type: string | string[] + supportsNegativeValues: boolean + values: Record & { + __BARE_VALUE__?: (value: NamedUtilityValue) => string | undefined + } + modifiers: 'any' | Record + }>, + ): void + + // addComponents(utilities: Record | Record[], options?: {}): void + // matchComponents( + // utilities: Record CssInJs>, + // options?: Partial<{ + // type: string | string[] + // supportsNegativeValues: boolean + // values: Record & { + // __BARE_VALUE__?: (value: NamedUtilityValue) => string | undefined + // } + // modifiers: 'any' | Record + // }>, + // ): void + + // theme(path: string, defaultValue?: any): any + // prefix(className: string): string +} + +import * as rpc from 'vscode-jsonrpc/node' + +export function buildPluginController(server: rpc.MessageConnection) { + let utilityMap = new Map< + string, + (value: string, extra: { modifier: string | null }) => CssInJs | CssInJs[] + >() + + let api = buildPluginApi(server, utilityMap) + + return { + api, + + matchUtility(id: string, value: string, modifier: string | null) { + let fn = utilityMap.get(id) + if (!fn) return + + return fn(value, { modifier }) + }, + } +} + +function buildPluginApi( + server: rpc.MessageConnection, + utilityMap: Map< + string, + (value: string, extra: { modifier: string | null }) => CssInJs | CssInJs[] + >, +): PluginAPI { + return { + addBase(base) { + server.sendNotification('@/plugin/add-base', { ast: base }) + }, + + addVariant(name, variant) { + server.sendNotification('@/plugin/add-variant', { name, format: variant }) + }, + + addUtilities(utilities, options) { + server.sendNotification('@/plugin/add-utilities', { ast: utilities, options }) + }, + + matchUtilities(utilities, options) { + let namesToIds: Record = {} + + for (let [name, fn] of Object.entries(utilities)) { + let id = Math.random().toString(36).slice(2) + namesToIds[name] = id + utilityMap.set(id, fn) + } + + server.sendNotification('@/plugin/match-utility', { + utilities: namesToIds, + }) + }, + } +} diff --git a/crates/plugin-example/node/src/plugin.ts b/crates/plugin-example/node/src/plugin.ts new file mode 100644 index 000000000..ee1d4f803 --- /dev/null +++ b/crates/plugin-example/node/src/plugin.ts @@ -0,0 +1,27 @@ +import type { PluginAPI } from './plugin-api' + +module.exports = function ({ addBase, addVariant, addUtilities, matchUtilities }: PluginAPI) { + addBase({ + div: { + transform: 'skewY(-10deg)', + }, + }) + + addVariant('hover', ':hover') + addVariant('marker', ['::marker', '* ::marker']) + + addUtilities({ + '.skew-10deg': { + transform: 'skewY(-10deg)', + }, + }) + + matchUtilities( + { skew: (value) => ({ transform: `skewY(${value})` }) }, + { + values: { + f10: '10deg', + }, + }, + ) +} diff --git a/crates/plugin-example/node/src/server.ts b/crates/plugin-example/node/src/server.ts new file mode 100644 index 000000000..b7529b3de --- /dev/null +++ b/crates/plugin-example/node/src/server.ts @@ -0,0 +1,33 @@ +import * as net from 'node:net' +import * as rpc from 'vscode-jsonrpc/node' +import { buildPluginController, PluginFn } from './plugin-api' + +let socket = await new Promise((resolve) => { + net.createServer(resolve).listen(12345) +}) + +let server = rpc.createMessageConnection(socket, socket) +let controller = buildPluginController(server) + +async function loadPlugin(path: string): Promise { + return import(path).then((m) => m.default ?? m) +} + +server.onRequest('@/plugins/load', async ({ plugins: paths }) => { + console.log('Loading…') + let plugins: PluginFn[] = await Promise.all(paths.map(loadPlugin)) + + for (let plugin of plugins) { + plugin(controller.api) + } + console.log('Loaded…') +}) + +server.onRequest('@/plugins/match-utility', async ({ id, value, modifier }) => { + let ast = controller.matchUtility(id, value, modifier) + + return { ast } +}) + +server.listen() +server.sendNotification('loaded') diff --git a/crates/plugin-example/src/app.rs b/crates/plugin-example/src/app.rs new file mode 100644 index 000000000..7d31668d0 --- /dev/null +++ b/crates/plugin-example/src/app.rs @@ -0,0 +1,111 @@ +use std::{collections::HashMap, error::Error}; + +use lsp_server::{Connection, Message, Notification, ReqQueue, Request, RequestId, Response}; +use serde_json::Value; +use tokio::sync::oneshot; + +type AppResult = Result>; +type ReqHandler = Box ()>; + +pub struct App { + connection: Connection, + queue: ReqQueue::, + utilities: HashMap::>, +} + +impl App { + pub fn new(connection: Connection) -> Self { + Self { + connection, + queue: ReqQueue::default(), + utilities: HashMap::new(), + } + } + + pub async fn main_loop(&mut self) -> AppResult<()> { + for msg in self.connection.receiver.clone() { + match msg { + Message::Request(req) => self.on_request(req).await?, + Message::Response(res) => self.on_response(res).await?, + Message::Notification(note) => self.on_note(note).await?, + _ => {}, + }; + } + + Ok(()) + } + + async fn on_request(&mut self, req: Request) -> AppResult<()> { + // + Ok(()) + } + + async fn on_response(&mut self, res: Response) -> AppResult<()> { + self.queue.outgoing.complete(res.id); + + Ok(()) + } + + async fn on_note(&mut self, note: Notification) -> AppResult<()> { + match ¬e.method[..] { + "@/plugin/match-utility" => { + self.on_match_utility(note.params)?; + }, + _ => {}, + }; + + Ok(()) + } + + pub async fn load_plugins(&mut self, paths: Vec) -> AppResult<()> { + let (tx, rx) = oneshot::channel::(); + + let request = self.queue.outgoing.register( + "@/plugins/load".to_owned(), + serde_json::json!({ + "plugins": paths, + }), + Box::new(move |app, res| { + tx.send(0).unwrap(); + }) + ); + + let id = request.id.clone(); + + self.connection.sender.send(Message::Request(request))?; + + rx.await?; + + Ok(()) + } + + fn on_match_utility(&mut self, params: Value) -> AppResult<()> { + let params = params["utilities"].as_object().unwrap(); + + for (key, value) in params { + let id = value.as_str().unwrap().to_owned(); + + self.utilities.entry(key.clone()).or_default().push(id); + } + + Ok(()) + } + + pub async fn call_match_utility(&mut self, name: &str, value: &str) -> AppResult<()> { + let utility_id = self.utilities.get(name).unwrap().first().unwrap().clone(); + + let request = self.queue.outgoing.register( + "@/plugins/match-utility".to_owned(), + serde_json::json!({ + "id": utility_id, + "value": value, + "modifier": null, + }), + 0 + ); + + self.connection.sender.send(Message::Request(request))?; + + Ok(()) + } +} diff --git a/crates/plugin-example/src/main.rs b/crates/plugin-example/src/main.rs new file mode 100644 index 000000000..a1d82e28a --- /dev/null +++ b/crates/plugin-example/src/main.rs @@ -0,0 +1,73 @@ +mod app; +mod pipes; + +use std::{collections::HashMap, error::Error, net::SocketAddr, process::{Command, Stdio}, thread, time::Duration}; + +use app::App; +use lsp_server::{Connection, Message, ReqQueue, Request}; +use tailwindcss_css::ast::{rule, AstNode, WalkAction}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut ast: Vec = vec![ + rule(b"@plugin \"./plugin.ts\"".to_vec(), vec![]), + ]; + + // 1. Collect paths of all plugins in the CSS + let mut plugins_paths: Vec = vec![]; + + for node in ast.iter_mut() { + node.walk(&mut |node, _| { + let AstNode::Rule { selector, .. } = node else { + return WalkAction::Continue; + }; + + if !selector.starts_with(b"@plugin") { + return WalkAction::Continue; + } + + let path = selector.split_at(7).1; + let path = &path[2..path.len()-1]; + let path = path.to_vec(); + + plugins_paths.push(unsafe { + String::from_utf8_unchecked(path) + }); + + WalkAction::Continue + }); + } + + // 2. Spawn node server + let mut node_server = Command::new("bun") + .args(["node/src/server.ts"]) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .spawn() + .unwrap(); + + // 3. Wait for the server to start + thread::sleep(Duration::from_millis(500)); + + let (conn, io_threads) = Connection::connect("127.0.0.1:12345".parse::()?)?; + + let mut app = App::new(conn); + + _ = app.main_loop().await; + + // 4. Load Plugins + app.load_plugins(plugins_paths).await?; + + // 6. Ask server to match utilities + app.call_match_utility("skew", "f10").await?; + + // 7. Wait for the server to respond + thread::sleep(Duration::from_millis(100)); + + // 7. Update the AST with the matched utilities + + io_threads.join()?; + node_server.kill()?; + + Ok(()) +} diff --git a/crates/plugin-example/src/pipes.rs b/crates/plugin-example/src/pipes.rs new file mode 100644 index 000000000..2fb7d16fe --- /dev/null +++ b/crates/plugin-example/src/pipes.rs @@ -0,0 +1,79 @@ +use std::{ + io::{self, stdin, stdout, BufReader, BufWriter, Write}, process::{Child, ChildStdin, ChildStdout}, thread +}; + +use log::debug; + +use crossbeam_channel::{bounded, Receiver, Sender}; +use lsp_server::Message; + +/// Creates an LSP connection via stdio. +pub fn process_transport( + child: &mut Child, +) -> (Sender, Receiver, IoThreads) { + let stdout = child.stdin.take().expect("failed to get child stdin"); + let stdin = child.stdout.take().expect("failed to get child stdout"); + + pipes_transport(stdout, stdin) +} + +/// Creates an LSP connection via stdio. +pub fn pipes_transport( + stdout: ChildStdin, + stdin: ChildStdout, +) -> (Sender, Receiver, IoThreads) { + // Send messages to the child process + let mut writer = stdout; + let (writer_sender, writer_receiver) = bounded::(0); + let writer = thread::spawn(move || { + writer_receiver.into_iter().try_for_each(|it| it.write(&mut writer)) + }); + + // Recieve messages from the child process + let mut reader = BufReader::with_capacity(1, stdin); + let (reader_sender, reader_receiver) = bounded::(0); + let reader = thread::spawn(move || { + loop { + let Some(msg) = Message::read(&mut reader)? else { + break; + }; + + let is_exit = matches!(&msg, Message::Notification(n) if n.method == "exit"); + + debug!("sending message {:#?}", msg); + reader_sender.send(msg).expect("receiver was dropped, failed to send a message"); + + if is_exit { + break; + } + } + Ok(()) + }); + + let threads = IoThreads { reader, writer }; + (writer_sender, reader_receiver, threads) +} + +pub struct IoThreads { + reader: thread::JoinHandle>, + writer: thread::JoinHandle>, +} + +impl IoThreads { + pub fn join(self) -> io::Result<()> { + match self.reader.join() { + Ok(r) => r?, + Err(err) => { + println!("reader panicked!"); + std::panic::panic_any(err) + } + } + match self.writer.join() { + Ok(r) => r, + Err(err) => { + println!("writer panicked!"); + std::panic::panic_any(err); + } + } + } +}