Skip to content

Commit 2cb1514

Browse files
authored
feat(native): parse declaration files natively behind the preview engine (#1014)
* feat(native): parse declaration files natively in sqlbuild-discovery Add native listing, reading and parsing for enum, constant, model schema, audit, SQL hook, SQL function, seed and macro files, in Python's discovery order, with a deferred outcome for inputs only Python can judge and a session that retains each collection and its layout for later stages. * feat(native): discover declaration files through the native session behind the preview engine Add the preview-tier DECLARATION_FILES stage. Each declaration collection is read once per pass through a retained native discovery session; Python builds the records, normalises constant values and parses schema columns, and re-runs its own parser for any file native rejects or defers, raising NativeStageMismatchError when the outcomes differ. Default engines are unchanged. * fix(native): show authored text unchanged in discovery failure messages Discovery decoded raw-name markers across whole failure messages, so a NUL authored in a header key (for example a model's descri\0ption) was rewritten under every engine. Layout failures now show each listed path as it is displayed when the message is built, and the binding passes message text through untouched. * fix(native): never reuse a native discovery session for edited inputs Document that a missing session means readers use the Python objects, and drop the session from the inputs each Rule fix pass lints.
1 parent 57c6da6 commit 2cb1514

74 files changed

Lines changed: 5369 additions & 64 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/sqlbuild-discovery/src/model_files/_helpers/locations.rs renamed to crates/sqlbuild-discovery/src/_helpers/locations.rs

File renamed without changes.

‎crates/sqlbuild-discovery/src/_helpers/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub(crate) mod header_keys;
2+
pub(crate) mod locations;
23
pub(crate) mod pool;
34
pub(crate) mod reading;
45
pub(crate) mod scoped_paths;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//! Python's `validate_resource_identity` for declaration names that are already SQL identifiers.
2+
3+
use crate::declaration_files::_helpers::checks::stops::ParseStop;
4+
use crate::models::{DiscoveryFailure, FailureKind};
5+
6+
const IDENTITY_HELP: &str = "Rename the authored identity and update its references, selectors, \
7+
and integration keys. SQLBuild does not silently normalize resource \
8+
identities. Double underscores remain valid.";
9+
const FALLBACK_IDENTITY: &str = "resource_name";
10+
11+
/// Reject a public identity unless it is lowercase snake_case; `name` matches `[A-Za-z_]\w*`.
12+
pub(crate) fn validate_public_identity(
13+
name: &str,
14+
kind: &str,
15+
file_path: &str,
16+
) -> Result<(), ParseStop> {
17+
if is_snake_case(name) {
18+
return Ok(());
19+
}
20+
Err(ParseStop::Failed(DiscoveryFailure {
21+
kind: FailureKind::ResourceIdentity,
22+
message: format!(
23+
"Invalid public {kind} identity '{name}' in {file_path}; use snake_case '{}'",
24+
suggested_identity(name)
25+
),
26+
help: Some(IDENTITY_HELP.to_owned()),
27+
}))
28+
}
29+
30+
/// `^[a-z](?:[a-z0-9_]*[a-z0-9])?$`.
31+
fn is_snake_case(name: &str) -> bool {
32+
let bytes = name.as_bytes();
33+
let tail_ok = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
34+
match bytes {
35+
[] => false,
36+
[first] => first.is_ascii_lowercase(),
37+
[first, middle @ .., last] => {
38+
first.is_ascii_lowercase()
39+
&& middle.iter().all(|byte| tail_ok(byte) || *byte == b'_')
40+
&& tail_ok(last)
41+
}
42+
}
43+
}
44+
45+
/// `suggest_resource_identity` for an ASCII identifier: split acronyms and words, then lowercase.
46+
fn suggested_identity(name: &str) -> String {
47+
let characters: Vec<char> = name.chars().collect();
48+
let mut acronyms: Vec<char> = Vec::with_capacity(characters.len() * 2);
49+
for (index, character) in characters.iter().enumerate() {
50+
let splits = index > 0
51+
&& characters[index - 1].is_ascii_uppercase()
52+
&& character.is_ascii_uppercase()
53+
&& characters
54+
.get(index + 1)
55+
.is_some_and(char::is_ascii_lowercase);
56+
if splits {
57+
acronyms.push('_');
58+
}
59+
acronyms.push(*character);
60+
}
61+
let mut words: String = String::with_capacity(acronyms.len() * 2);
62+
for (index, character) in acronyms.iter().enumerate() {
63+
if index > 0
64+
&& (acronyms[index - 1].is_ascii_lowercase() || acronyms[index - 1].is_ascii_digit())
65+
&& character.is_ascii_uppercase()
66+
{
67+
words.push('_');
68+
}
69+
words.push(character.to_ascii_lowercase());
70+
}
71+
let corrected: &str = words.trim_matches('_');
72+
if corrected.is_empty() {
73+
FALLBACK_IDENTITY.to_owned()
74+
} else {
75+
corrected.to_owned()
76+
}
77+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub(crate) mod identities;
2+
pub(crate) mod python_values;
3+
pub(crate) mod stops;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! The Python type of a projected header value, as the discovery parsers' `isinstance` checks see it.
2+
3+
use crate::declaration_files::_helpers::checks::stops::ParseStop;
4+
use crate::models::{DiscoveryFailure, FailureKind};
5+
use sqlbuild_core::text::main::python_strip::python_strip;
6+
use sqlbuild_sqltext::compiler::models::AuthoredValue;
7+
8+
/// The Python type a header value projects to.
9+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10+
pub(crate) enum PythonType {
11+
None,
12+
Bool,
13+
Int,
14+
Float,
15+
Str,
16+
List,
17+
Dict,
18+
/// `AuthoredSqlSet`, a tuple, a `constant(...)` call or a hook entry.
19+
Other,
20+
}
21+
22+
/// The projected type; a bare word with non-ASCII text may be a Unicode number, so it defers.
23+
pub(crate) fn python_type(value: &AuthoredValue) -> Result<PythonType, ParseStop> {
24+
Ok(match value {
25+
AuthoredValue::Null => PythonType::None,
26+
AuthoredValue::Boolean(_) => PythonType::Bool,
27+
AuthoredValue::BareWord(word) => word_type(word)?,
28+
AuthoredValue::String(_) => PythonType::Str,
29+
AuthoredValue::List(_) => PythonType::List,
30+
AuthoredValue::Map(_) => PythonType::Dict,
31+
AuthoredValue::Set(_)
32+
| AuthoredValue::Tuple(_)
33+
| AuthoredValue::TypedConstant(_)
34+
| AuthoredValue::InlineSqlHook(_)
35+
| AuthoredValue::NamedSqlHook(..)
36+
| AuthoredValue::PythonHook(..) => PythonType::Other,
37+
})
38+
}
39+
40+
/// The text of a value that projects to `str`, or `None` for any other type.
41+
pub(crate) fn python_str(value: &AuthoredValue) -> Result<Option<&str>, ParseStop> {
42+
Ok(match value {
43+
AuthoredValue::String(text) => Some(text),
44+
AuthoredValue::BareWord(word) if word_type(word)? == PythonType::Str => Some(word),
45+
_ => None,
46+
})
47+
}
48+
49+
/// `isinstance(value, str) and value.strip()`.
50+
pub(crate) fn non_empty_str(value: &AuthoredValue) -> Result<Option<&str>, ParseStop> {
51+
Ok(python_str(value)?.filter(|text| !python_strip(text).is_empty()))
52+
}
53+
54+
/// Python's `dict.get(key)`.
55+
pub(crate) fn get<'a>(
56+
values: &'a [(String, AuthoredValue)],
57+
key: &str,
58+
) -> Option<&'a AuthoredValue> {
59+
values
60+
.iter()
61+
.find(|(candidate, _)| candidate == key)
62+
.map(|(_, value)| value)
63+
}
64+
65+
/// The keys outside `allowed`, sorted as Python's `sorted(set(values) - allowed)`.
66+
pub(crate) fn unknown_keys(values: &[(String, AuthoredValue)], allowed: &[&str]) -> Vec<String> {
67+
let mut unknown: Vec<String> = values
68+
.iter()
69+
.map(|(key, _)| key.clone())
70+
.filter(|key| !allowed.contains(&key.as_str()))
71+
.collect();
72+
unknown.sort_unstable();
73+
unknown.dedup();
74+
unknown
75+
}
76+
77+
/// `re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", text)`.
78+
pub(crate) fn is_identifier(text: &str) -> bool {
79+
let mut characters = text.chars();
80+
characters
81+
.next()
82+
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
83+
&& characters.all(|character| character.is_ascii_alphanumeric() || character == '_')
84+
}
85+
86+
/// A failure of `kind` with `message` and no help.
87+
pub(crate) fn failure(kind: FailureKind, message: String) -> ParseStop {
88+
ParseStop::Failed(DiscoveryFailure::new(kind, message))
89+
}
90+
91+
/// `_parse_word_value`: `true`, `false` and `null` are parsed as values; numbers become numbers.
92+
fn word_type(word: &str) -> Result<PythonType, ParseStop> {
93+
if !word.is_ascii() {
94+
return Err(ParseStop::Deferred);
95+
}
96+
Ok(match word {
97+
"true" | "false" => PythonType::Bool,
98+
"null" => PythonType::None,
99+
_ if is_integer(word) => PythonType::Int,
100+
_ if is_float(word) => PythonType::Float,
101+
_ => PythonType::Str,
102+
})
103+
}
104+
105+
fn unsigned(word: &str) -> &str {
106+
word.strip_prefix(['+', '-']).unwrap_or(word)
107+
}
108+
109+
/// `^[+-]?\d+$` over ASCII text.
110+
fn is_integer(word: &str) -> bool {
111+
let digits = unsigned(word);
112+
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
113+
}
114+
115+
/// `^[+-]?(?:\d+\.\d*|\d*\.\d+)$` over ASCII text.
116+
fn is_float(word: &str) -> bool {
117+
let Some((whole, fraction)) = unsigned(word).split_once('.') else {
118+
return false;
119+
};
120+
let digits = |part: &str| part.bytes().all(|byte| byte.is_ascii_digit());
121+
digits(whole) && digits(fraction) && !(whole.is_empty() && fraction.is_empty())
122+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//! Why native parsing of one declaration file stopped.
2+
3+
use crate::models::DiscoveryFailure;
4+
5+
/// The first failure Python raises for the file, or a file only the Python parser reproduces.
6+
#[derive(Clone, Debug, PartialEq)]
7+
pub(crate) enum ParseStop {
8+
Failed(DiscoveryFailure),
9+
Deferred,
10+
}
11+
12+
impl From<DiscoveryFailure> for ParseStop {
13+
fn from(failure: DiscoveryFailure) -> Self {
14+
Self::Failed(failure)
15+
}
16+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//! Read and parse the files of one collection into its retained native form.
2+
3+
use crate::declaration_files::_helpers::listing::collections::{
4+
NamedKind, declaration_files, file_name, function_files, named_declaration_files, read_files,
5+
seed_files,
6+
};
7+
use crate::declaration_files::_helpers::parsing::audit_files::parse_audit_file;
8+
use crate::declaration_files::_helpers::parsing::constant_files::parse_constant_file;
9+
use crate::declaration_files::_helpers::parsing::enum_files::parse_enum_file;
10+
use crate::declaration_files::_helpers::parsing::function_files::parse_function_file;
11+
use crate::declaration_files::_helpers::parsing::hook_files::parse_hook_file;
12+
use crate::declaration_files::_helpers::parsing::schema_files::parse_schema_file;
13+
use crate::declaration_files::models::{
14+
CollectionFailure, CollectionKind, CollectionRequest, DeclarationCollection,
15+
DeclarationFileOptions, DiscoverySession, MacroFile, ScopedFile, SeedFile,
16+
};
17+
use crate::declarations::models::DeclarationKind;
18+
use crate::models::{DiscoveredFile, FileOutcome, ProjectRoot, StageFailure};
19+
use crate::tree::models::ProjectTree;
20+
21+
/// The project a collection is read from and the options its parsers use.
22+
pub(crate) struct CollectionSource<'a> {
23+
pub(crate) session: &'a DiscoverySession,
24+
pub(crate) tree: &'a ProjectTree,
25+
}
26+
27+
/// Read and parse the collection `request` names.
28+
pub(crate) fn read_collection(
29+
source: &CollectionSource<'_>,
30+
request: CollectionRequest,
31+
) -> DeclarationCollection {
32+
let tree: &ProjectTree = source.tree;
33+
let root: &ProjectRoot = &source.session.root;
34+
let options: &DeclarationFileOptions = &source.session.options;
35+
let isolate_kind: bool = request.isolate_kind;
36+
match request.kind {
37+
CollectionKind::Enums => DeclarationCollection::Enums(
38+
declaration_files(source, DeclarationKind::Enum, isolate_kind).map(|files| {
39+
read_files(root, tree, files, |file_path, _, contents| {
40+
parse_enum_file(file_path, contents)
41+
})
42+
}),
43+
),
44+
CollectionKind::Constants => DeclarationCollection::Constants(
45+
declaration_files(source, DeclarationKind::Constant, isolate_kind).map(|files| {
46+
read_files(root, tree, files, |file_path, _, contents| {
47+
parse_constant_file(file_path, contents)
48+
})
49+
}),
50+
),
51+
CollectionKind::Macros => DeclarationCollection::Macros(
52+
declaration_files(source, DeclarationKind::Macro, isolate_kind).map(|files| {
53+
read_files(root, tree, files, |_, _, contents| {
54+
Ok(MacroFile { contents })
55+
})
56+
}),
57+
),
58+
CollectionKind::ModelSchemas => DeclarationCollection::ModelSchemas(
59+
named_declaration_files(source, &[NamedKind::Schema], false).map(|files| {
60+
read_files(root, tree, files, |file_path, _, contents| {
61+
parse_schema_file(file_path, contents)
62+
})
63+
}),
64+
),
65+
CollectionKind::Audits => DeclarationCollection::Audits(
66+
named_declaration_files(source, &[NamedKind::Audit, NamedKind::SingularAudit], false)
67+
.map(|files| {
68+
read_files(root, tree, files, |file_path, _, contents| {
69+
parse_audit_file(file_path, contents, options)
70+
})
71+
}),
72+
),
73+
CollectionKind::SqlHooks => DeclarationCollection::SqlHooks(
74+
named_declaration_files(source, &[NamedKind::SqlHook], true).map(|files| {
75+
read_files(root, tree, files, |file_path, relative_path, contents| {
76+
parse_hook_file(file_path, stem(file_name(relative_path)), contents, options)
77+
})
78+
}),
79+
),
80+
CollectionKind::SqlFunctions => {
81+
DeclarationCollection::SqlFunctions(function_files(tree).map(|files| {
82+
read_files(root, tree, files, |file_path, _, contents| {
83+
parse_function_file(file_path, contents, options)
84+
})
85+
}))
86+
}
87+
CollectionKind::Seeds => DeclarationCollection::Seeds(
88+
seed_files(tree).map(|paths| paths.into_iter().map(seed_file).collect()),
89+
),
90+
}
91+
}
92+
93+
fn seed_file(relative_path: String) -> ScopedFile<SeedFile> {
94+
ScopedFile {
95+
file: DiscoveredFile {
96+
relative_path,
97+
outcome: FileOutcome::Parsed(SeedFile),
98+
},
99+
scope: None,
100+
}
101+
}
102+
103+
/// The collection `kind` failing as a whole.
104+
pub(crate) fn failed_collection(
105+
kind: CollectionKind,
106+
failure: StageFailure,
107+
) -> DeclarationCollection {
108+
let failure = CollectionFailure::Stage(failure);
109+
match kind {
110+
CollectionKind::Enums => DeclarationCollection::Enums(Err(failure)),
111+
CollectionKind::Constants => DeclarationCollection::Constants(Err(failure)),
112+
CollectionKind::ModelSchemas => DeclarationCollection::ModelSchemas(Err(failure)),
113+
CollectionKind::SqlFunctions => DeclarationCollection::SqlFunctions(Err(failure)),
114+
CollectionKind::SqlHooks => DeclarationCollection::SqlHooks(Err(failure)),
115+
CollectionKind::Audits => DeclarationCollection::Audits(Err(failure)),
116+
CollectionKind::Seeds => DeclarationCollection::Seeds(Err(failure)),
117+
CollectionKind::Macros => DeclarationCollection::Macros(Err(failure)),
118+
}
119+
}
120+
121+
/// Python's `Path.stem` of a file name.
122+
fn stem(file_name: &str) -> &str {
123+
match file_name.rfind('.') {
124+
Some(index) if index > 0 && index + 1 < file_name.len() => &file_name[..index],
125+
_ => file_name,
126+
}
127+
}

0 commit comments

Comments
 (0)