Skip to content
Merged
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
1 change: 1 addition & 0 deletions crates/plotnik-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use std::num::NonZeroU16;

mod interner;
mod invariants;
pub mod utils;

pub use interner::{Interner, Symbol};

Expand Down
115 changes: 115 additions & 0 deletions crates/plotnik-core/src/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/// Convert snake_case or kebab-case to PascalCase.
///
/// Normalizes words separated by `_`, `-`, or `.`. If the input is already
/// PascalCase (starts uppercase, no separators), it is returned unchanged.
///
/// # Examples
/// ```
/// use plotnik_core::utils::to_pascal_case;
/// assert_eq!(to_pascal_case("foo_bar"), "FooBar");
/// assert_eq!(to_pascal_case("FOO_BAR"), "FooBar");
/// assert_eq!(to_pascal_case("FooBar"), "FooBar"); // idempotent
/// ```
pub fn to_pascal_case(s: &str) -> String {
fn is_separator(c: char) -> bool {
matches!(c, '_' | '-' | '.')
}

let has_separator = s.chars().any(is_separator);
let has_lowercase = s.chars().any(|c| c.is_ascii_lowercase());
let starts_uppercase = s.chars().next().is_some_and(|c| c.is_ascii_uppercase());

// Already PascalCase: starts uppercase, has lowercase, no separators
if starts_uppercase && has_lowercase && !has_separator {
return s.to_string();
}

let mut result = String::with_capacity(s.len());
let mut capitalize_next = true;
for c in s.chars() {
if is_separator(c) {
capitalize_next = true;
continue;
}
if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(c.to_ascii_lowercase());
}
}
result
}

/// Convert PascalCase or camelCase to snake_case.
///
/// # Examples
/// ```
/// use plotnik_core::utils::to_snake_case;
/// assert_eq!(to_snake_case("FooBar"), "foo_bar");
/// assert_eq!(to_snake_case("fooBar"), "foo_bar");
/// ```
pub fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_ascii_uppercase() {
if i > 0 && !result.ends_with('_') {
result.push('_');
}
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn pascal_case_from_snake() {
assert_eq!(to_pascal_case("foo_bar"), "FooBar");
assert_eq!(to_pascal_case("foo"), "Foo");
assert_eq!(to_pascal_case("_foo"), "Foo");
assert_eq!(to_pascal_case("foo_"), "Foo");
}

#[test]
fn pascal_case_normalizes() {
assert_eq!(to_pascal_case("FOO_BAR"), "FooBar");
assert_eq!(to_pascal_case("FOO"), "Foo");
assert_eq!(to_pascal_case("FOOBAR"), "Foobar");
}

#[test]
fn pascal_case_idempotent() {
assert_eq!(to_pascal_case("FooBar"), "FooBar");
assert_eq!(to_pascal_case("QRow"), "QRow");
assert_eq!(to_pascal_case("Q"), "Q");
}

#[test]
fn pascal_case_from_kebab() {
assert_eq!(to_pascal_case("foo-bar"), "FooBar");
assert_eq!(to_pascal_case("foo-bar-baz"), "FooBarBaz");
}

#[test]
fn pascal_case_from_dotted() {
assert_eq!(to_pascal_case("foo.bar"), "FooBar");
}

#[test]
fn snake_case_from_pascal() {
assert_eq!(to_snake_case("FooBar"), "foo_bar");
assert_eq!(to_snake_case("Foo"), "foo");
}

#[test]
fn snake_case_from_camel() {
assert_eq!(to_snake_case("fooBar"), "foo_bar");
assert_eq!(to_snake_case("fooBarBaz"), "foo_bar_baz");
}
}
19 changes: 2 additions & 17 deletions crates/plotnik-lib/src/bytecode/emit/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use std::collections::hash_map::Entry;
use std::collections::{BTreeSet, HashMap, HashSet};

use plotnik_core::utils::to_pascal_case;

use crate::bytecode::module::{Module, StringsView, TypesView};
use crate::bytecode::type_meta::{TypeDef, TypeKind};
use crate::bytecode::{EntrypointsView, QTypeId};
Expand Down Expand Up @@ -770,23 +772,6 @@ struct NamingContext {
field_name: Option<String>,
}

fn to_pascal_case(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut capitalize_next = true;

for c in s.chars() {
if c == '_' || c == '-' || c == '.' {
capitalize_next = true;
} else if capitalize_next {
result.extend(c.to_uppercase());
capitalize_next = false;
} else {
result.push(c);
}
}
result
}

/// Emit TypeScript from a bytecode module.
pub fn emit_typescript(module: &Module) -> String {
TsEmitter::new(module, EmitConfig::default()).emit()
Expand Down
31 changes: 1 addition & 30 deletions crates/plotnik-lib/src/parser/grammar/utils.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,4 @@
pub(crate) fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_ascii_uppercase() {
if i > 0 && !result.ends_with('_') {
result.push('_');
}
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}

pub(crate) fn to_pascal_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = true;
for c in s.chars() {
if c == '_' || c == '-' || c == '.' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(c.to_ascii_lowercase());
}
}
result
}
pub(crate) use plotnik_core::utils::{to_pascal_case, to_snake_case};

pub(crate) fn capitalize_first(s: &str) -> String {
assert!(!s.is_empty(), "capitalize_first: called with empty string");
Expand Down
29 changes: 28 additions & 1 deletion crates/plotnik-lib/src/query/dependencies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! which is useful for passes that need to process dependencies before
//! dependents (like type inference).

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

use indexmap::{IndexMap, IndexSet};
use plotnik_core::{Interner, Symbol};
Expand Down Expand Up @@ -39,6 +39,12 @@ pub struct DependencyAnalysis {

/// Maps DefId to definition name Symbol (indexed by DefId).
def_names: Vec<Symbol>,

/// Set of recursive definition names.
///
/// A definition is recursive if it's in an SCC with >1 member,
/// or it's a single-member SCC that references itself.
recursive_defs: HashSet<String>,
}

impl DependencyAnalysis {
Expand Down Expand Up @@ -82,6 +88,14 @@ impl DependencyAnalysis {
pub fn name_to_def(&self) -> &HashMap<Symbol, DefId> {
&self.name_to_def
}

/// Returns true if this definition is recursive.
///
/// A definition is recursive if it's part of a mutual recursion group (SCC > 1),
/// or it's a single definition that references itself.
pub fn is_recursive(&self, name: &str) -> bool {
self.recursive_defs.contains(name)
}
}

/// Analyze dependencies between definitions.
Expand All @@ -97,8 +111,20 @@ pub fn analyze_dependencies(
// Assign DefIds in SCC order (leaves first, so dependencies get lower IDs)
let mut name_to_def = HashMap::new();
let mut def_names = Vec::new();
let mut recursive_defs = HashSet::new();

for scc in &sccs {
// Mark recursive definitions
if scc.len() > 1 {
// Mutual recursion: all members are recursive
recursive_defs.extend(scc.iter().cloned());
} else if let Some(name) = scc.first()
&& let Some(body) = symbol_table.get(name)
&& super::refs::contains_ref(body, name)
{
recursive_defs.insert(name.clone());
}

for name in scc {
let sym = interner.intern(name);
let def_id = DefId::from_raw(def_names.len() as u32);
Expand All @@ -111,6 +137,7 @@ pub fn analyze_dependencies(
sccs,
name_to_def,
def_names,
recursive_defs,
}
}

Expand Down
74 changes: 26 additions & 48 deletions crates/plotnik-lib/src/query/link.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
//! Link pass: resolve node types and fields against tree-sitter grammar.
//!
//! Three-phase approach:
//! 1. Collect and resolve all node type names (NamedNode, AnonymousNode)
//! 2. Collect and resolve all field names (FieldExpr, NegatedField)
//! 3. Validate structural constraints (field on node type, child type for field)
//! Two-phase approach:
//! 1. Resolve all symbols (node types and fields) against grammar
//! 2. Validate structural constraints (field on node type, child type for field)

use std::collections::HashMap;

Expand Down Expand Up @@ -84,14 +83,13 @@ impl<'a, 'q> Linker<'a, 'q> {
}

fn link(&mut self, root: &ast::Root) {
self.resolve_node_types(root);
self.resolve_fields(root);
self.resolve_symbols(root);
self.validate_structure(root);
}

fn resolve_node_types(&mut self, root: &ast::Root) {
let mut collector = NodeTypeCollector { linker: self };
collector.visit(root);
fn resolve_symbols(&mut self, root: &ast::Root) {
let mut resolver = SymbolResolver { linker: self };
resolver.visit(root);
}

fn resolve_named_node(&mut self, node: &NamedNode) {
Expand Down Expand Up @@ -139,11 +137,6 @@ impl<'a, 'q> Linker<'a, 'q> {
}
}

fn resolve_fields(&mut self, root: &ast::Root) {
let mut collector = FieldCollector { linker: self };
collector.visit(root);
}

fn resolve_field_by_token(&mut self, name_token: Option<SyntaxToken>) {
let Some(name_token) = name_token else {
return;
Expand Down Expand Up @@ -403,17 +396,23 @@ struct ValidationContext {
parent_range: TextRange,
}

struct NodeTypeCollector<'l, 'a, 'q> {
/// Combined symbol resolver for node types and fields.
struct SymbolResolver<'l, 'a, 'q> {
linker: &'l mut Linker<'a, 'q>,
}

impl Visitor for NodeTypeCollector<'_, '_, '_> {
impl Visitor for SymbolResolver<'_, '_, '_> {
fn visit(&mut self, root: &ast::Root) {
walk(self, root);
}

fn visit_named_node(&mut self, node: &ast::NamedNode) {
self.linker.resolve_named_node(node);

for neg in node.as_cst().children().filter_map(ast::NegatedField::cast) {
self.linker.resolve_field_by_token(neg.name());
}

super::visitor::walk_named_node(self, node);
}

Expand All @@ -433,47 +432,26 @@ impl Visitor for NodeTypeCollector<'_, '_, '_> {
self.linker
.node_type_ids
.insert(token_src(&value_token, self.linker.source()), resolved);

if let Some(id) = resolved {
let sym = self.linker.interner.intern(value);
self.linker.output.node_type_ids.entry(sym).or_insert(id);
return;
}

if resolved.is_none() {
self.linker
.diagnostics
.report(
self.linker.source_id,
DiagnosticKind::UnknownNodeType,
value_token.text_range(),
)
.message(value)
.emit();
}
}
}

struct FieldCollector<'l, 'a, 'q> {
linker: &'l mut Linker<'a, 'q>,
}

impl Visitor for FieldCollector<'_, '_, '_> {
fn visit(&mut self, root: &ast::Root) {
walk(self, root);
}

fn visit_named_node(&mut self, node: &ast::NamedNode) {
for child in node.as_cst().children() {
if let Some(neg) = ast::NegatedField::cast(child) {
self.linker.resolve_field_by_token(neg.name());
}
}

super::visitor::walk_named_node(self, node);
self.linker
.diagnostics
.report(
self.linker.source_id,
DiagnosticKind::UnknownNodeType,
value_token.text_range(),
)
.message(value)
.emit();
}

fn visit_field_expr(&mut self, field: &ast::FieldExpr) {
self.linker.resolve_field_by_token(field.name());

super::visitor::walk_field_expr(self, field);
}
}
1 change: 1 addition & 0 deletions crates/plotnik-lib/src/query/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod dump;
mod invariants;
mod printer;
mod refs;
mod source_map;
mod utils;
pub use printer::QueryPrinter;
Expand Down
Loading