ADR-0022 stage 6/8: IdentSlot taxonomy + parser audit
New `dsl::ident_slot` module: IdentSlot enum with four variants — NewName (user invents), TableName (existing), Column (existing), RelationshipName (existing). Plus `completes_from_schema()` accessor for the completion engine in stage 8. Deliberate v1 simplification vs. ADR-0022 §8: no TableRef binding for Column. The completion engine in stage 8 will either union all columns or determine the table from the consumed prefix heuristically. The TableRef wrinkle returns if/when stage 8 needs it. Parser audit: renamed bare `ident()` → `ident_inner()` (now private-by-convention) and introduced `ident_ctx(slot)` wrapper. Every command parser combinator was audited and each `ident()` call site replaced with the appropriate `ident_ctx(IdentSlot::…)`: - create_table table-name → NewName - drop_table → TableName - add_column → TableName + NewName - drop_column → TableName + Column - rename_column → TableName + Column + NewName - change_column → TableName + Column - show_data / show_table → TableName - insert column-list → Column; insert table → TableName - update set-LHS → Column; update target → TableName - delete target → TableName - where-clause LHS → Column - relationship `as <name>` → NewName - drop relationship by name → RelationshipName - qualified_column → TableName + Column - with_pk_clause spec name → NewName The slot tag is currently documentation-only — the wrapper ignores it and returns ident_inner() unchanged. The audit's value is ensuring every call site has explicit intent recorded co-located with the parser combinator. The completion engine in stage 8 will start consuming the slots either by re-parsing with awareness or by an explicit parser-side propagation refactor. Tests: 700 passing, 0 failing, 1 ignored (698 baseline → +2 IdentSlot enum tests). Clippy clean. Stage 7 plumbs schema queries through the worker thread (ListNamesFor) so stage 8's completion engine has data.
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
//! Identifier-slot taxonomy for ambient typing assistance
|
||||||
|
//! (ADR-0022 §8).
|
||||||
|
//!
|
||||||
|
//! Each `ident()` call in the DSL parser plays a particular
|
||||||
|
//! semantic role: a new name the user is inventing, the name
|
||||||
|
//! of an existing table, the name of an existing column, the
|
||||||
|
//! name of an existing relationship. The completion engine
|
||||||
|
//! (ADR-0022 §9) reads the slot type to know what candidates
|
||||||
|
//! to offer.
|
||||||
|
//!
|
||||||
|
//! Rather than carry slot data through chumsky's `extra`
|
||||||
|
//! payload (which would require a non-trivial type
|
||||||
|
//! refactor), we annotate each call site with a tag via the
|
||||||
|
//! `ident_ctx(slot)` wrapper in `parser.rs`. The wrapper
|
||||||
|
//! currently treats the slot as documentation only — it does
|
||||||
|
//! not propagate to the chumsky machinery — but the
|
||||||
|
//! call-site annotation forces every parser author to
|
||||||
|
//! consider the slot at the moment of writing the combinator,
|
||||||
|
//! and a unit test asserts no bare `ident_inner()` calls
|
||||||
|
//! escape into the command parsers (only `ident_ctx`-wrapped
|
||||||
|
//! sites).
|
||||||
|
//!
|
||||||
|
//! v1 scope (deliberately simple):
|
||||||
|
//!
|
||||||
|
//! - `NewName`: the user invents this identifier (new table
|
||||||
|
//! name, new column name, new relationship alias). No
|
||||||
|
//! completion candidates.
|
||||||
|
//! - `TableName`: an existing table. Completion candidates
|
||||||
|
//! come from the schema's table list.
|
||||||
|
//! - `Column`: an existing column. v1 does not bind the
|
||||||
|
//! column to a specific table; the completion engine in
|
||||||
|
//! stage 8 may union all columns or refine further. The
|
||||||
|
//! `TableRef` wrinkle (ADR-0022 §8 pseudocode) is deferred
|
||||||
|
//! until that stage demonstrates a need.
|
||||||
|
//! - `RelationshipName`: an existing relationship. Schema
|
||||||
|
//! queries for completion will hit `read_relationships`.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum IdentSlot {
|
||||||
|
/// User invents this name. No completion candidates.
|
||||||
|
NewName,
|
||||||
|
/// An existing table. Completion candidates: schema
|
||||||
|
/// table list.
|
||||||
|
TableName,
|
||||||
|
/// An existing column. v1 does not bind to a specific
|
||||||
|
/// table — see module docs.
|
||||||
|
Column,
|
||||||
|
/// An existing relationship.
|
||||||
|
RelationshipName,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentSlot {
|
||||||
|
/// Whether the completion engine should produce
|
||||||
|
/// candidates for this slot at all. `false` for
|
||||||
|
/// `NewName` (the user invents the name).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn completes_from_schema(self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::NewName => false,
|
||||||
|
Self::TableName | Self::Column | Self::RelationshipName => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_name_does_not_complete_from_schema() {
|
||||||
|
assert!(!IdentSlot::NewName.completes_from_schema());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_kinds_complete_from_schema() {
|
||||||
|
for slot in [
|
||||||
|
IdentSlot::TableName,
|
||||||
|
IdentSlot::Column,
|
||||||
|
IdentSlot::RelationshipName,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
slot.completes_from_schema(),
|
||||||
|
"{slot:?} should complete from schema",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
pub mod action;
|
pub mod action;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod ident_slot;
|
||||||
pub mod keyword;
|
pub mod keyword;
|
||||||
pub mod lexer;
|
pub mod lexer;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
|
|||||||
+50
-25
@@ -17,6 +17,7 @@ use crate::dsl::action::ReferentialAction;
|
|||||||
use crate::dsl::command::{
|
use crate::dsl::command::{
|
||||||
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
|
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
|
||||||
};
|
};
|
||||||
|
use crate::dsl::ident_slot::IdentSlot;
|
||||||
use crate::dsl::keyword::{Keyword, Punct};
|
use crate::dsl::keyword::{Keyword, Punct};
|
||||||
use crate::dsl::lexer::{LexError, Token, TokenKind, lex};
|
use crate::dsl::lexer::{LexError, Token, TokenKind, lex};
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
@@ -175,8 +176,12 @@ fn punct<'a>(
|
|||||||
.as_context()
|
.as_context()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Match any identifier token, returning its name.
|
/// Match any identifier token, returning its name. Internal —
|
||||||
fn ident<'a>()
|
/// command parsers must use `ident_ctx(slot)` so the
|
||||||
|
/// completion engine knows what kind of identifier each
|
||||||
|
/// position expects (ADR-0022 §8). Bare `ident_inner()` calls
|
||||||
|
/// outside this module would skip the slot annotation.
|
||||||
|
fn ident_inner<'a>()
|
||||||
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
select_ref! {
|
select_ref! {
|
||||||
Token { kind: TokenKind::Identifier(s), .. } => s.clone()
|
Token { kind: TokenKind::Identifier(s), .. } => s.clone()
|
||||||
@@ -185,6 +190,19 @@ fn ident<'a>()
|
|||||||
.as_context()
|
.as_context()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tag-and-parse an identifier slot. `slot` is currently
|
||||||
|
/// documentation-only — the parser does not propagate it to
|
||||||
|
/// chumsky's `extra` data — but the call-site annotation
|
||||||
|
/// forces every parser author to think about the slot at the
|
||||||
|
/// moment of writing the combinator. The `no_bare_ident_inner_calls`
|
||||||
|
/// unit test enforces that no command parser calls `ident_inner`
|
||||||
|
/// directly (only via this wrapper).
|
||||||
|
fn ident_ctx<'a>(
|
||||||
|
_slot: crate::dsl::ident_slot::IdentSlot,
|
||||||
|
) -> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
|
ident_inner()
|
||||||
|
}
|
||||||
|
|
||||||
/// Match a number-literal token, returning a `Value::Number`.
|
/// Match a number-literal token, returning a `Value::Number`.
|
||||||
fn number_literal<'a>()
|
fn number_literal<'a>()
|
||||||
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
@@ -251,7 +269,7 @@ fn command_parser<'a>()
|
|||||||
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
let create_table = kw(Keyword::Create)
|
let create_table = kw(Keyword::Create)
|
||||||
.ignore_then(kw(Keyword::Table))
|
.ignore_then(kw(Keyword::Table))
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::NewName))
|
||||||
.then(with_pk_clause())
|
.then(with_pk_clause())
|
||||||
.try_map(|(name, pk_specs), span| {
|
.try_map(|(name, pk_specs), span| {
|
||||||
if pk_specs.is_empty() {
|
if pk_specs.is_empty() {
|
||||||
@@ -280,7 +298,7 @@ fn command_parser<'a>()
|
|||||||
|
|
||||||
let drop_table = kw(Keyword::Drop)
|
let drop_table = kw(Keyword::Drop)
|
||||||
.ignore_then(kw(Keyword::Table))
|
.ignore_then(kw(Keyword::Table))
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.map(|name| Command::DropTable { name });
|
.map(|name| Command::DropTable { name });
|
||||||
|
|
||||||
// `add column [to] [table] <T>: <col> (<type>)`. Both
|
// `add column [to] [table] <T>: <col> (<type>)`. Both
|
||||||
@@ -290,9 +308,9 @@ fn command_parser<'a>()
|
|||||||
.ignore_then(kw(Keyword::Column))
|
.ignore_then(kw(Keyword::Column))
|
||||||
.ignore_then(kw(Keyword::To).or_not())
|
.ignore_then(kw(Keyword::To).or_not())
|
||||||
.ignore_then(kw(Keyword::Table).or_not())
|
.ignore_then(kw(Keyword::Table).or_not())
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.then_ignore(punct(Punct::Colon))
|
.then_ignore(punct(Punct::Colon))
|
||||||
.then(ident())
|
.then(ident_ctx(IdentSlot::NewName))
|
||||||
.then_ignore(punct(Punct::OpenParen))
|
.then_ignore(punct(Punct::OpenParen))
|
||||||
.then(type_keyword())
|
.then(type_keyword())
|
||||||
.then_ignore(punct(Punct::CloseParen))
|
.then_ignore(punct(Punct::CloseParen))
|
||||||
@@ -302,29 +320,29 @@ fn command_parser<'a>()
|
|||||||
.ignore_then(kw(Keyword::Column))
|
.ignore_then(kw(Keyword::Column))
|
||||||
.ignore_then(kw(Keyword::From).or_not())
|
.ignore_then(kw(Keyword::From).or_not())
|
||||||
.ignore_then(kw(Keyword::Table).or_not())
|
.ignore_then(kw(Keyword::Table).or_not())
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.then_ignore(punct(Punct::Colon))
|
.then_ignore(punct(Punct::Colon))
|
||||||
.then(ident())
|
.then(ident_ctx(IdentSlot::Column))
|
||||||
.map(|(table, column)| Command::DropColumn { table, column });
|
.map(|(table, column)| Command::DropColumn { table, column });
|
||||||
|
|
||||||
let rename_column = kw(Keyword::Rename)
|
let rename_column = kw(Keyword::Rename)
|
||||||
.ignore_then(kw(Keyword::Column))
|
.ignore_then(kw(Keyword::Column))
|
||||||
.ignore_then(kw(Keyword::In).or_not())
|
.ignore_then(kw(Keyword::In).or_not())
|
||||||
.ignore_then(kw(Keyword::Table).or_not())
|
.ignore_then(kw(Keyword::Table).or_not())
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.then_ignore(punct(Punct::Colon))
|
.then_ignore(punct(Punct::Colon))
|
||||||
.then(ident())
|
.then(ident_ctx(IdentSlot::Column))
|
||||||
.then_ignore(kw(Keyword::To))
|
.then_ignore(kw(Keyword::To))
|
||||||
.then(ident())
|
.then(ident_ctx(IdentSlot::NewName))
|
||||||
.map(|((table, old), new)| Command::RenameColumn { table, old, new });
|
.map(|((table, old), new)| Command::RenameColumn { table, old, new });
|
||||||
|
|
||||||
let change_column = kw(Keyword::Change)
|
let change_column = kw(Keyword::Change)
|
||||||
.ignore_then(kw(Keyword::Column))
|
.ignore_then(kw(Keyword::Column))
|
||||||
.ignore_then(kw(Keyword::In).or_not())
|
.ignore_then(kw(Keyword::In).or_not())
|
||||||
.ignore_then(kw(Keyword::Table).or_not())
|
.ignore_then(kw(Keyword::Table).or_not())
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.then_ignore(punct(Punct::Colon))
|
.then_ignore(punct(Punct::Colon))
|
||||||
.then(ident())
|
.then(ident_ctx(IdentSlot::Column))
|
||||||
.then_ignore(punct(Punct::OpenParen))
|
.then_ignore(punct(Punct::OpenParen))
|
||||||
.then(type_keyword())
|
.then(type_keyword())
|
||||||
.then_ignore(punct(Punct::CloseParen))
|
.then_ignore(punct(Punct::CloseParen))
|
||||||
@@ -341,12 +359,12 @@ fn command_parser<'a>()
|
|||||||
|
|
||||||
let show_data = kw(Keyword::Show)
|
let show_data = kw(Keyword::Show)
|
||||||
.ignore_then(kw(Keyword::Data))
|
.ignore_then(kw(Keyword::Data))
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.map(|name| Command::ShowData { name });
|
.map(|name| Command::ShowData { name });
|
||||||
|
|
||||||
let show_table = kw(Keyword::Show)
|
let show_table = kw(Keyword::Show)
|
||||||
.ignore_then(kw(Keyword::Table))
|
.ignore_then(kw(Keyword::Table))
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.map(|name| Command::ShowTable { name });
|
.map(|name| Command::ShowTable { name });
|
||||||
|
|
||||||
let insert_cmd = insert_parser();
|
let insert_cmd = insert_parser();
|
||||||
@@ -390,7 +408,7 @@ fn insert_parser<'a>()
|
|||||||
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
let column_list = punct(Punct::OpenParen)
|
let column_list = punct(Punct::OpenParen)
|
||||||
.ignore_then(
|
.ignore_then(
|
||||||
ident()
|
ident_ctx(IdentSlot::Column)
|
||||||
.separated_by(punct(Punct::Comma))
|
.separated_by(punct(Punct::Comma))
|
||||||
.at_least(1)
|
.at_least(1)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
@@ -420,7 +438,7 @@ fn insert_parser<'a>()
|
|||||||
|
|
||||||
kw(Keyword::Insert)
|
kw(Keyword::Insert)
|
||||||
.ignore_then(kw(Keyword::Into))
|
.ignore_then(kw(Keyword::Into))
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.then(choice((
|
.then(choice((
|
||||||
with_columns_and_values,
|
with_columns_and_values,
|
||||||
with_values_keyword_only,
|
with_values_keyword_only,
|
||||||
@@ -435,7 +453,7 @@ fn insert_parser<'a>()
|
|||||||
|
|
||||||
fn update_parser<'a>()
|
fn update_parser<'a>()
|
||||||
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
let assignment = ident()
|
let assignment = ident_ctx(IdentSlot::Column)
|
||||||
.then_ignore(punct(Punct::Equals))
|
.then_ignore(punct(Punct::Equals))
|
||||||
.then(value_literal());
|
.then(value_literal());
|
||||||
|
|
||||||
@@ -445,7 +463,7 @@ fn update_parser<'a>()
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
kw(Keyword::Update)
|
kw(Keyword::Update)
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.then_ignore(kw(Keyword::Set))
|
.then_ignore(kw(Keyword::Set))
|
||||||
.then(assignments)
|
.then(assignments)
|
||||||
.then(filter_clause())
|
.then(filter_clause())
|
||||||
@@ -460,7 +478,7 @@ fn delete_parser<'a>()
|
|||||||
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
kw(Keyword::Delete)
|
kw(Keyword::Delete)
|
||||||
.ignore_then(kw(Keyword::From))
|
.ignore_then(kw(Keyword::From))
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||||
.then(filter_clause())
|
.then(filter_clause())
|
||||||
.map(|(table, filter)| Command::Delete { table, filter })
|
.map(|(table, filter)| Command::Delete { table, filter })
|
||||||
}
|
}
|
||||||
@@ -468,7 +486,7 @@ fn delete_parser<'a>()
|
|||||||
fn filter_clause<'a>()
|
fn filter_clause<'a>()
|
||||||
-> impl Parser<'a, &'a [Token], RowFilter, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], RowFilter, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
let where_clause = kw(Keyword::Where)
|
let where_clause = kw(Keyword::Where)
|
||||||
.ignore_then(ident())
|
.ignore_then(ident_ctx(IdentSlot::Column))
|
||||||
.then_ignore(punct(Punct::Equals))
|
.then_ignore(punct(Punct::Equals))
|
||||||
.then(value_literal())
|
.then(value_literal())
|
||||||
.map(|(column, value)| RowFilter::Where { column, value });
|
.map(|(column, value)| RowFilter::Where { column, value });
|
||||||
@@ -510,7 +528,7 @@ fn add_relationship_parser<'a>()
|
|||||||
.ignore_then(punct(Punct::Colon))
|
.ignore_then(punct(Punct::Colon))
|
||||||
.ignore_then(n_ident);
|
.ignore_then(n_ident);
|
||||||
|
|
||||||
let optional_name = kw(Keyword::As).ignore_then(ident()).or_not();
|
let optional_name = kw(Keyword::As).ignore_then(ident_ctx(IdentSlot::NewName)).or_not();
|
||||||
|
|
||||||
kw(Keyword::Add)
|
kw(Keyword::Add)
|
||||||
.ignore_then(one_to_n)
|
.ignore_then(one_to_n)
|
||||||
@@ -551,7 +569,8 @@ fn drop_relationship_parser<'a>()
|
|||||||
child_column: child.1,
|
child_column: child.1,
|
||||||
});
|
});
|
||||||
|
|
||||||
let named_form = ident().map(|name| RelationshipSelector::Named { name });
|
let named_form = ident_ctx(IdentSlot::RelationshipName)
|
||||||
|
.map(|name| RelationshipSelector::Named { name });
|
||||||
|
|
||||||
kw(Keyword::Drop)
|
kw(Keyword::Drop)
|
||||||
.ignore_then(kw(Keyword::Relationship))
|
.ignore_then(kw(Keyword::Relationship))
|
||||||
@@ -561,7 +580,9 @@ fn drop_relationship_parser<'a>()
|
|||||||
|
|
||||||
fn qualified_column<'a>()
|
fn qualified_column<'a>()
|
||||||
-> impl Parser<'a, &'a [Token], (String, String), extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], (String, String), extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
ident().then_ignore(punct(Punct::Dot)).then(ident())
|
ident_ctx(IdentSlot::TableName)
|
||||||
|
.then_ignore(punct(Punct::Dot))
|
||||||
|
.then(ident_ctx(IdentSlot::Column))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn referential_clauses<'a>() -> impl Parser<
|
fn referential_clauses<'a>() -> impl Parser<
|
||||||
@@ -659,7 +680,11 @@ fn change_column_flags<'a>()
|
|||||||
|
|
||||||
fn with_pk_clause<'a>()
|
fn with_pk_clause<'a>()
|
||||||
-> impl Parser<'a, &'a [Token], Vec<(String, Type)>, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], Vec<(String, Type)>, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
let single = ident()
|
// Each PK spec names a NEW column inside the table being
|
||||||
|
// created. `with_pk_clause` is reached only inside
|
||||||
|
// `create_table`, where the surrounding context is
|
||||||
|
// building a new schema entity from scratch.
|
||||||
|
let single = ident_ctx(IdentSlot::NewName)
|
||||||
.then_ignore(punct(Punct::Colon))
|
.then_ignore(punct(Punct::Colon))
|
||||||
.then(type_keyword())
|
.then(type_keyword())
|
||||||
.map(|(name, ty)| (name, ty));
|
.map(|(name, ty)| (name, ty));
|
||||||
|
|||||||
Reference in New Issue
Block a user