Skip to content

Commit 81ce645

Browse files
authored
refactor: consolidate compiler utilities and passes (#200)
1 parent 71e4779 commit 81ce645

10 files changed

Lines changed: 257 additions & 145 deletions

File tree

crates/plotnik-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use std::num::NonZeroU16;
1515

1616
mod interner;
1717
mod invariants;
18+
pub mod utils;
1819

1920
pub use interner::{Interner, Symbol};
2021

crates/plotnik-core/src/utils.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/// Convert snake_case or kebab-case to PascalCase.
2+
///
3+
/// Normalizes words separated by `_`, `-`, or `.`. If the input is already
4+
/// PascalCase (starts uppercase, no separators), it is returned unchanged.
5+
///
6+
/// # Examples
7+
/// ```
8+
/// use plotnik_core::utils::to_pascal_case;
9+
/// assert_eq!(to_pascal_case("foo_bar"), "FooBar");
10+
/// assert_eq!(to_pascal_case("FOO_BAR"), "FooBar");
11+
/// assert_eq!(to_pascal_case("FooBar"), "FooBar"); // idempotent
12+
/// ```
13+
pub fn to_pascal_case(s: &str) -> String {
14+
fn is_separator(c: char) -> bool {
15+
matches!(c, '_' | '-' | '.')
16+
}
17+
18+
let has_separator = s.chars().any(is_separator);
19+
let has_lowercase = s.chars().any(|c| c.is_ascii_lowercase());
20+
let starts_uppercase = s.chars().next().is_some_and(|c| c.is_ascii_uppercase());
21+
22+
// Already PascalCase: starts uppercase, has lowercase, no separators
23+
if starts_uppercase && has_lowercase && !has_separator {
24+
return s.to_string();
25+
}
26+
27+
let mut result = String::with_capacity(s.len());
28+
let mut capitalize_next = true;
29+
for c in s.chars() {
30+
if is_separator(c) {
31+
capitalize_next = true;
32+
continue;
33+
}
34+
if capitalize_next {
35+
result.push(c.to_ascii_uppercase());
36+
capitalize_next = false;
37+
} else {
38+
result.push(c.to_ascii_lowercase());
39+
}
40+
}
41+
result
42+
}
43+
44+
/// Convert PascalCase or camelCase to snake_case.
45+
///
46+
/// # Examples
47+
/// ```
48+
/// use plotnik_core::utils::to_snake_case;
49+
/// assert_eq!(to_snake_case("FooBar"), "foo_bar");
50+
/// assert_eq!(to_snake_case("fooBar"), "foo_bar");
51+
/// ```
52+
pub fn to_snake_case(s: &str) -> String {
53+
let mut result = String::new();
54+
for (i, c) in s.chars().enumerate() {
55+
if c.is_ascii_uppercase() {
56+
if i > 0 && !result.ends_with('_') {
57+
result.push('_');
58+
}
59+
result.push(c.to_ascii_lowercase());
60+
} else {
61+
result.push(c);
62+
}
63+
}
64+
result
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::*;
70+
71+
#[test]
72+
fn pascal_case_from_snake() {
73+
assert_eq!(to_pascal_case("foo_bar"), "FooBar");
74+
assert_eq!(to_pascal_case("foo"), "Foo");
75+
assert_eq!(to_pascal_case("_foo"), "Foo");
76+
assert_eq!(to_pascal_case("foo_"), "Foo");
77+
}
78+
79+
#[test]
80+
fn pascal_case_normalizes() {
81+
assert_eq!(to_pascal_case("FOO_BAR"), "FooBar");
82+
assert_eq!(to_pascal_case("FOO"), "Foo");
83+
assert_eq!(to_pascal_case("FOOBAR"), "Foobar");
84+
}
85+
86+
#[test]
87+
fn pascal_case_idempotent() {
88+
assert_eq!(to_pascal_case("FooBar"), "FooBar");
89+
assert_eq!(to_pascal_case("QRow"), "QRow");
90+
assert_eq!(to_pascal_case("Q"), "Q");
91+
}
92+
93+
#[test]
94+
fn pascal_case_from_kebab() {
95+
assert_eq!(to_pascal_case("foo-bar"), "FooBar");
96+
assert_eq!(to_pascal_case("foo-bar-baz"), "FooBarBaz");
97+
}
98+
99+
#[test]
100+
fn pascal_case_from_dotted() {
101+
assert_eq!(to_pascal_case("foo.bar"), "FooBar");
102+
}
103+
104+
#[test]
105+
fn snake_case_from_pascal() {
106+
assert_eq!(to_snake_case("FooBar"), "foo_bar");
107+
assert_eq!(to_snake_case("Foo"), "foo");
108+
}
109+
110+
#[test]
111+
fn snake_case_from_camel() {
112+
assert_eq!(to_snake_case("fooBar"), "foo_bar");
113+
assert_eq!(to_snake_case("fooBarBaz"), "foo_bar_baz");
114+
}
115+
}

crates/plotnik-lib/src/bytecode/emit/typescript.rs

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use std::collections::hash_map::Entry;
77
use std::collections::{BTreeSet, HashMap, HashSet};
88

9+
use plotnik_core::utils::to_pascal_case;
10+
911
use crate::bytecode::module::{Module, StringsView, TypesView};
1012
use crate::bytecode::type_meta::{TypeDef, TypeKind};
1113
use crate::bytecode::{EntrypointsView, QTypeId};
@@ -770,23 +772,6 @@ struct NamingContext {
770772
field_name: Option<String>,
771773
}
772774

773-
fn to_pascal_case(s: &str) -> String {
774-
let mut result = String::with_capacity(s.len());
775-
let mut capitalize_next = true;
776-
777-
for c in s.chars() {
778-
if c == '_' || c == '-' || c == '.' {
779-
capitalize_next = true;
780-
} else if capitalize_next {
781-
result.extend(c.to_uppercase());
782-
capitalize_next = false;
783-
} else {
784-
result.push(c);
785-
}
786-
}
787-
result
788-
}
789-
790775
/// Emit TypeScript from a bytecode module.
791776
pub fn emit_typescript(module: &Module) -> String {
792777
TsEmitter::new(module, EmitConfig::default()).emit()

crates/plotnik-lib/src/parser/grammar/utils.rs

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,4 @@
1-
pub(crate) fn to_snake_case(s: &str) -> String {
2-
let mut result = String::new();
3-
for (i, c) in s.chars().enumerate() {
4-
if c.is_ascii_uppercase() {
5-
if i > 0 && !result.ends_with('_') {
6-
result.push('_');
7-
}
8-
result.push(c.to_ascii_lowercase());
9-
} else {
10-
result.push(c);
11-
}
12-
}
13-
result
14-
}
15-
16-
pub(crate) fn to_pascal_case(s: &str) -> String {
17-
let mut result = String::new();
18-
let mut capitalize_next = true;
19-
for c in s.chars() {
20-
if c == '_' || c == '-' || c == '.' {
21-
capitalize_next = true;
22-
} else if capitalize_next {
23-
result.push(c.to_ascii_uppercase());
24-
capitalize_next = false;
25-
} else {
26-
result.push(c.to_ascii_lowercase());
27-
}
28-
}
29-
result
30-
}
1+
pub(crate) use plotnik_core::utils::{to_pascal_case, to_snake_case};
312

323
pub(crate) fn capitalize_first(s: &str) -> String {
334
assert!(!s.is_empty(), "capitalize_first: called with empty string");

crates/plotnik-lib/src/query/dependencies.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! which is useful for passes that need to process dependencies before
99
//! dependents (like type inference).
1010
11-
use std::collections::HashMap;
11+
use std::collections::{HashMap, HashSet};
1212

1313
use indexmap::{IndexMap, IndexSet};
1414
use plotnik_core::{Interner, Symbol};
@@ -39,6 +39,12 @@ pub struct DependencyAnalysis {
3939

4040
/// Maps DefId to definition name Symbol (indexed by DefId).
4141
def_names: Vec<Symbol>,
42+
43+
/// Set of recursive definition names.
44+
///
45+
/// A definition is recursive if it's in an SCC with >1 member,
46+
/// or it's a single-member SCC that references itself.
47+
recursive_defs: HashSet<String>,
4248
}
4349

4450
impl DependencyAnalysis {
@@ -82,6 +88,14 @@ impl DependencyAnalysis {
8288
pub fn name_to_def(&self) -> &HashMap<Symbol, DefId> {
8389
&self.name_to_def
8490
}
91+
92+
/// Returns true if this definition is recursive.
93+
///
94+
/// A definition is recursive if it's part of a mutual recursion group (SCC > 1),
95+
/// or it's a single definition that references itself.
96+
pub fn is_recursive(&self, name: &str) -> bool {
97+
self.recursive_defs.contains(name)
98+
}
8599
}
86100

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

101116
for scc in &sccs {
117+
// Mark recursive definitions
118+
if scc.len() > 1 {
119+
// Mutual recursion: all members are recursive
120+
recursive_defs.extend(scc.iter().cloned());
121+
} else if let Some(name) = scc.first()
122+
&& let Some(body) = symbol_table.get(name)
123+
&& super::refs::contains_ref(body, name)
124+
{
125+
recursive_defs.insert(name.clone());
126+
}
127+
102128
for name in scc {
103129
let sym = interner.intern(name);
104130
let def_id = DefId::from_raw(def_names.len() as u32);
@@ -111,6 +137,7 @@ pub fn analyze_dependencies(
111137
sccs,
112138
name_to_def,
113139
def_names,
140+
recursive_defs,
114141
}
115142
}
116143

crates/plotnik-lib/src/query/link.rs

Lines changed: 26 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
//! Link pass: resolve node types and fields against tree-sitter grammar.
22
//!
3-
//! Three-phase approach:
4-
//! 1. Collect and resolve all node type names (NamedNode, AnonymousNode)
5-
//! 2. Collect and resolve all field names (FieldExpr, NegatedField)
6-
//! 3. Validate structural constraints (field on node type, child type for field)
3+
//! Two-phase approach:
4+
//! 1. Resolve all symbols (node types and fields) against grammar
5+
//! 2. Validate structural constraints (field on node type, child type for field)
76
87
use std::collections::HashMap;
98

@@ -84,14 +83,13 @@ impl<'a, 'q> Linker<'a, 'q> {
8483
}
8584

8685
fn link(&mut self, root: &ast::Root) {
87-
self.resolve_node_types(root);
88-
self.resolve_fields(root);
86+
self.resolve_symbols(root);
8987
self.validate_structure(root);
9088
}
9189

92-
fn resolve_node_types(&mut self, root: &ast::Root) {
93-
let mut collector = NodeTypeCollector { linker: self };
94-
collector.visit(root);
90+
fn resolve_symbols(&mut self, root: &ast::Root) {
91+
let mut resolver = SymbolResolver { linker: self };
92+
resolver.visit(root);
9593
}
9694

9795
fn resolve_named_node(&mut self, node: &NamedNode) {
@@ -139,11 +137,6 @@ impl<'a, 'q> Linker<'a, 'q> {
139137
}
140138
}
141139

142-
fn resolve_fields(&mut self, root: &ast::Root) {
143-
let mut collector = FieldCollector { linker: self };
144-
collector.visit(root);
145-
}
146-
147140
fn resolve_field_by_token(&mut self, name_token: Option<SyntaxToken>) {
148141
let Some(name_token) = name_token else {
149142
return;
@@ -403,17 +396,23 @@ struct ValidationContext {
403396
parent_range: TextRange,
404397
}
405398

406-
struct NodeTypeCollector<'l, 'a, 'q> {
399+
/// Combined symbol resolver for node types and fields.
400+
struct SymbolResolver<'l, 'a, 'q> {
407401
linker: &'l mut Linker<'a, 'q>,
408402
}
409403

410-
impl Visitor for NodeTypeCollector<'_, '_, '_> {
404+
impl Visitor for SymbolResolver<'_, '_, '_> {
411405
fn visit(&mut self, root: &ast::Root) {
412406
walk(self, root);
413407
}
414408

415409
fn visit_named_node(&mut self, node: &ast::NamedNode) {
416410
self.linker.resolve_named_node(node);
411+
412+
for neg in node.as_cst().children().filter_map(ast::NegatedField::cast) {
413+
self.linker.resolve_field_by_token(neg.name());
414+
}
415+
417416
super::visitor::walk_named_node(self, node);
418417
}
419418

@@ -433,47 +432,26 @@ impl Visitor for NodeTypeCollector<'_, '_, '_> {
433432
self.linker
434433
.node_type_ids
435434
.insert(token_src(&value_token, self.linker.source()), resolved);
435+
436436
if let Some(id) = resolved {
437437
let sym = self.linker.interner.intern(value);
438438
self.linker.output.node_type_ids.entry(sym).or_insert(id);
439+
return;
439440
}
440441

441-
if resolved.is_none() {
442-
self.linker
443-
.diagnostics
444-
.report(
445-
self.linker.source_id,
446-
DiagnosticKind::UnknownNodeType,
447-
value_token.text_range(),
448-
)
449-
.message(value)
450-
.emit();
451-
}
452-
}
453-
}
454-
455-
struct FieldCollector<'l, 'a, 'q> {
456-
linker: &'l mut Linker<'a, 'q>,
457-
}
458-
459-
impl Visitor for FieldCollector<'_, '_, '_> {
460-
fn visit(&mut self, root: &ast::Root) {
461-
walk(self, root);
462-
}
463-
464-
fn visit_named_node(&mut self, node: &ast::NamedNode) {
465-
for child in node.as_cst().children() {
466-
if let Some(neg) = ast::NegatedField::cast(child) {
467-
self.linker.resolve_field_by_token(neg.name());
468-
}
469-
}
470-
471-
super::visitor::walk_named_node(self, node);
442+
self.linker
443+
.diagnostics
444+
.report(
445+
self.linker.source_id,
446+
DiagnosticKind::UnknownNodeType,
447+
value_token.text_range(),
448+
)
449+
.message(value)
450+
.emit();
472451
}
473452

474453
fn visit_field_expr(&mut self, field: &ast::FieldExpr) {
475454
self.linker.resolve_field_by_token(field.name());
476-
477455
super::visitor::walk_field_expr(self, field);
478456
}
479457
}

crates/plotnik-lib/src/query/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod dump;
22
mod invariants;
33
mod printer;
4+
mod refs;
45
mod source_map;
56
mod utils;
67
pub use printer::QueryPrinter;

0 commit comments

Comments
 (0)