Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,19 @@ crates/
recursion.rs # Escape analysis (recursion validation)
shapes.rs # Shape inference
*_tests.rs # Test files per module
infer/ # Type inference and emission
mod.rs # Re-exports, TypePrinter builder
types.rs # Type IR (TypeDef, Field, etc.)
tyton.rs # Tyton → TypeDef conversion
printer.rs # TypePrinter API
emit/ # Language-specific emitters
mod.rs # Emitter trait, common utilities
rust.rs # Rust type emission
typescript.rs # TypeScript type emission
*_tests.rs # Test files per module
lib.rs # Re-exports Query, Diagnostics, Error
plotnik-cli/ # CLI tool
src/commands/ # Subcommands (debug, docs, langs)
src/commands/ # Subcommands (debug, docs, infer, langs)
plotnik-langs/ # Tree-sitter language bindings
docs/
REFERENCE.md # Language specification
Expand All @@ -59,6 +69,7 @@ Module = "what", function = "action".
Run: `cargo run -p plotnik-cli -- <command>`

- `debug` — Inspect queries/sources
- `infer` — Generate type definitions from queries
- `docs [topic]` — Print docs (reference, examples)
- `langs` — List supported languages

Expand All @@ -76,6 +87,27 @@ cargo run -p plotnik-cli -- debug -s app.ts --raw
cargo run -p plotnik-cli -- debug -q '(function_declaration) @fn' -s app.ts -l typescript
```

### infer options

Input: `-q/--query <Q>`, `--query-file <F>`

Output language: `-l/--lang <rust|ts|typescript>`

Common: `--entry-name <NAME>`, `--color <auto|always|never>`

Rust-specific: `--indirection <box|rc|arc>`, `--derive <traits>`, `--no-derive`

TypeScript-specific: `--optional <null|undefined|questionmark>`, `--export`, `--readonly`, `--type-alias`, `--node-type <NAME>`, `--nested`

```sh
cargo run -p plotnik-cli -- infer -q '(identifier) @id' -l rust
cargo run -p plotnik-cli -- infer -q '(fn)' -l rust --derive debug,clone
cargo run -p plotnik-cli -- infer -q '(fn)' -l rust --no-derive
cargo run -p plotnik-cli -- infer -q '(identifier)' -l ts --export
cargo run -p plotnik-cli -- infer -q '(identifier)?' -l ts --optional undefined
cargo run -p plotnik-cli -- infer -q '(fn)' -l ts --readonly --type-alias
```

## Syntax

Grammar: `(type)`, `[a b]` (alt), `{a b}` (seq), `_` (wildcard), `@name`, `::Type`, `field:`, `*+?`, `"lit"`/`'lit'`, `(a/b)` (supertype), `(ERROR)`, `Name = expr` (def), `[A: ... B: ...]` (tagged alt)
Expand Down
101 changes: 101 additions & 0 deletions crates/plotnik-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@ use std::path::PathBuf;

use clap::{Args, Parser, Subcommand, ValueEnum};

#[derive(Clone, Copy, Debug, Default, ValueEnum)]
pub enum OutputLang {
#[default]
Rust,
Typescript,
Ts,
}

#[derive(Clone, Copy, Debug, Default, ValueEnum)]
pub enum IndirectionChoice {
#[default]
Box,
Rc,
Arc,
}

#[derive(Clone, Copy, Debug, Default, ValueEnum)]
pub enum OptionalChoice {
#[default]
Null,
Undefined,
#[value(name = "questionmark")]
QuestionMark,
}

#[derive(Clone, Copy, Debug, Default, ValueEnum)]
pub enum ColorChoice {
#[default]
Expand Down Expand Up @@ -52,6 +77,29 @@ pub enum Command {
output: OutputArgs,
},

/// Infer and emit types from a query
#[command(after_help = r#"EXAMPLES:
plotnik infer -q '(identifier) @id' -l rust
plotnik infer -q '(function_declaration name: (identifier) @name) @fn' -l ts --export
plotnik infer --query-file query.plot -l rust --derive debug,clone,partialeq"#)]
Infer {
#[command(flatten)]
query: QueryArgs,

/// Output language
#[arg(short = 'l', long, value_name = "LANG")]
lang: OutputLang,

#[command(flatten)]
common: InferCommonArgs,

#[command(flatten)]
rust: RustArgs,

#[command(flatten)]
typescript: TypeScriptArgs,
},

/// Print documentation
Docs {
/// Topic to display (e.g., "reference", "examples")
Expand Down Expand Up @@ -112,3 +160,56 @@ pub struct OutputArgs {
#[arg(long)]
pub cardinalities: bool,
}

#[derive(Args)]
pub struct InferCommonArgs {
/// Name for the entry point type (default: QueryResult)
#[arg(long, value_name = "NAME")]
pub entry_name: Option<String>,

/// Colorize diagnostics output
#[arg(long, default_value = "auto", value_name = "WHEN")]
pub color: ColorChoice,
}

#[derive(Args)]
pub struct RustArgs {
/// Indirection type for cyclic references
#[arg(long, value_name = "TYPE")]
pub indirection: Option<IndirectionChoice>,

/// Derive macros (comma-separated: debug, clone, partialeq)
#[arg(long, value_name = "TRAITS", value_delimiter = ',')]
pub derive: Option<Vec<String>>,

/// Emit no derive macros
#[arg(long)]
pub no_derive: bool,
}

#[derive(Args)]
pub struct TypeScriptArgs {
/// How to represent optional values
#[arg(long, value_name = "STYLE")]
pub optional: Option<OptionalChoice>,

/// Add export keyword to types
#[arg(long)]
pub export: bool,

/// Make fields readonly
#[arg(long)]
pub readonly: bool,

/// Use type aliases instead of interfaces
#[arg(long)]
pub type_alias: bool,

/// Name for the Node type (default: SyntaxNode)
#[arg(long, value_name = "NAME")]
pub node_type: Option<String>,

/// Emit nested synthetic types instead of inlining
#[arg(long)]
pub nested: bool,
}
146 changes: 146 additions & 0 deletions crates/plotnik-cli/src/commands/infer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
use std::fs;
use std::io::{self, Read};

use plotnik_lib::Query;
use plotnik_lib::infer::{Indirection, OptionalStyle};

use crate::cli::{IndirectionChoice, OptionalChoice, OutputLang};

pub struct InferArgs {
pub query_text: Option<String>,
pub query_file: Option<std::path::PathBuf>,
pub lang: OutputLang,
pub entry_name: Option<String>,
pub color: bool,
// Rust options
pub indirection: Option<IndirectionChoice>,
pub derive: Option<Vec<String>>,
pub no_derive: bool,
// TypeScript options
pub optional: Option<OptionalChoice>,
pub export: bool,
pub readonly: bool,
pub type_alias: bool,
pub node_type: Option<String>,
pub nested: bool,
}

pub fn run(args: InferArgs) {
if let Err(msg) = validate(&args) {
eprintln!("error: {}", msg);
std::process::exit(1);
}

let query_source = load_query(&args);

let query = Query::try_from(query_source.as_str()).unwrap_or_else(|e| {
eprintln!("error: {}", e);
std::process::exit(1);
});

if !query.is_valid() {
eprint!(
"{}",
query
.diagnostics()
.render_colored(&query_source, args.color)
);
std::process::exit(1);
}

let output = emit_types(&query, &args);
println!("{}", output);

if query.diagnostics().has_warnings() {
eprint!(
"{}",
query
.diagnostics()
.render_colored(&query_source, args.color)
);
}
}

fn validate(args: &InferArgs) -> Result<(), &'static str> {
if args.query_text.is_none() && args.query_file.is_none() {
return Err("query input required: -q/--query or --query-file");
}

Ok(())
}

fn load_query(args: &InferArgs) -> String {
if let Some(ref text) = args.query_text {
return text.clone();
}
if let Some(ref path) = args.query_file {
if path.as_os_str() == "-" {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.expect("failed to read stdin");
return buf;
}
return fs::read_to_string(path).expect("failed to read query file");
}
unreachable!()
}

fn emit_types(query: &Query<'_>, args: &InferArgs) -> String {
let mut printer = query.type_printer();

if let Some(ref name) = args.entry_name {
printer = printer.entry_name(name);
}

match args.lang {
OutputLang::Rust => emit_rust(printer, args),
OutputLang::Typescript | OutputLang::Ts => emit_typescript(printer, args),
}
}

fn emit_rust(printer: plotnik_lib::infer::TypePrinter<'_>, args: &InferArgs) -> String {
let mut rust = printer.rust();

if let Some(ind) = args.indirection {
let indirection = match ind {
IndirectionChoice::Box => Indirection::Box,
IndirectionChoice::Rc => Indirection::Rc,
IndirectionChoice::Arc => Indirection::Arc,
};
rust = rust.indirection(indirection);
}

if args.no_derive {
rust = rust.derive(&[]);
} else if let Some(ref traits) = args.derive {
let trait_refs: Vec<&str> = traits.iter().map(|s| s.as_str()).collect();
rust = rust.derive(&trait_refs);
}

rust.render()
}

fn emit_typescript(printer: plotnik_lib::infer::TypePrinter<'_>, args: &InferArgs) -> String {
let mut ts = printer.typescript();

if let Some(opt) = args.optional {
let style = match opt {
OptionalChoice::Null => OptionalStyle::Null,
OptionalChoice::Undefined => OptionalStyle::Undefined,
OptionalChoice::QuestionMark => OptionalStyle::QuestionMark,
};
ts = ts.optional(style);
}

ts = ts.export(args.export);
ts = ts.readonly(args.readonly);
ts = ts.type_alias(args.type_alias);
ts = ts.nested(args.nested);

if let Some(ref name) = args.node_type {
ts = ts.node_type(name);
}

ts.render()
}
1 change: 1 addition & 0 deletions crates/plotnik-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod debug;
pub mod docs;
pub mod infer;
pub mod langs;
25 changes: 25 additions & 0 deletions crates/plotnik-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod commands;

use cli::{Cli, Command};
use commands::debug::DebugArgs;
use commands::infer::InferArgs;

fn main() {
let cli = <Cli as clap::Parser>::parse();
Expand All @@ -28,6 +29,30 @@ fn main() {
color: output.color.should_colorize(),
});
}
Command::Infer {
query,
lang,
common,
rust,
typescript,
} => {
commands::infer::run(InferArgs {
query_text: query.query_text,
query_file: query.query_file,
lang,
entry_name: common.entry_name,
color: common.color.should_colorize(),
indirection: rust.indirection,
derive: rust.derive,
no_derive: rust.no_derive,
optional: typescript.optional,
export: typescript.export,
readonly: typescript.readonly,
type_alias: typescript.type_alias,
node_type: typescript.node_type,
nested: typescript.nested,
});
}
Command::Docs { topic } => {
commands::docs::run(topic.as_deref());
}
Expand Down
Loading