Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7a573c4
yeast: Order AST dump fields by schema-declared order
tausbn Jul 16, 2026
8206f6f
swift-syntax-rs: Fold local and stdlib operators
tausbn Jul 16, 2026
2e74c1d
yeast: Desugar an externally-built AST, and validate by field name
tausbn Jul 16, 2026
bbf52ce
tree-sitter-extractor: Split direct and desugaring extractors
tausbn Jul 17, 2026
79954ce
unified: Add the swift-syntax parser and unresolved operator sequence…
tausbn Jul 17, 2026
ad2ce93
unified: Port top-level, literal, and name rules to swift-syntax
tausbn Jul 16, 2026
8328fba
unified: Port operator rules to swift-syntax
tausbn Jul 17, 2026
437e482
unified: Port variable-binding rules to swift-syntax
tausbn Jul 17, 2026
191fc54
unified: Port type-expression rules to swift-syntax
tausbn Jul 17, 2026
52cdf59
unified: Port function, call, and member-access rules to swift-syntax
tausbn Jul 17, 2026
6ad1fac
unified: Port closure rules to swift-syntax
tausbn Jul 17, 2026
2de1549
unified: Port control-flow and pattern rules to swift-syntax
tausbn Jul 20, 2026
2fcbc9b
unified: Port loop rules to swift-syntax
tausbn Jul 20, 2026
4c764b5
unified: Port collection rules to swift-syntax
tausbn Jul 20, 2026
6275977
unified: Port optional and error-handling rules to swift-syntax
tausbn Jul 20, 2026
37654b4
unified: Port import rules to swift-syntax
tausbn Jul 20, 2026
8e6651a
unified: Port type-container declarations to swift-syntax
tausbn Jul 20, 2026
ec0c49a
unified: Port property accessor rules to swift-syntax
tausbn Jul 22, 2026
90ea1b5
unified: Port enum-case rules to swift-syntax
tausbn Jul 23, 2026
c4fbd74
unified: Port constructor and related declaration rules to swift-syntax
tausbn Jul 23, 2026
5c5fd5e
unified: Switch the Swift front-end to swift-syntax
tausbn Jul 23, 2026
20a2537
unified: Regenerate the raw-AST corpus section for swift-syntax
tausbn Jul 23, 2026
046c88a
unified: Regenerate the enhanced getter/setter property corpus case
tausbn Jul 23, 2026
c50bcba
swift-syntax-rs: Degrade gracefully without a Swift toolchain
tausbn Jul 23, 2026
17cbb4e
unified: Harden the external Swift parser integration
tausbn Jul 24, 2026
7721ce2
unified: Add swift_node_types.yml to the extractor's compile_data
tausbn Jul 24, 2026
e3a0822
unified: Package the swift-syntax parser in the extractor pack
tausbn Jul 24, 2026
ba26e1d
unified: Add corpus tests for nested types
tausbn Jul 27, 2026
a3d9492
Merge branch 'main' into tausbn/swift-syntax-rs-sequenced
tausbn Jul 28, 2026
a2ff35a
unified: Fix Bazel formatting errors
tausbn Jul 28, 2026
ceffe40
unified: Remove references to Swift input schema generation
tausbn Jul 28, 2026
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
19 changes: 19 additions & 0 deletions unified/extractor/ast_types.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,19 @@ supertypes:
- throw_expr
- try_expr
- switch_expr
- unresolved_operator_sequence
- unsupported_node
expr_or_pattern:
- expr
- pattern
expr_or_type:
- expr
- type_expr
# An element of an `unresolved_operator_sequence`: either an operand (`expr`)
# or one of the infix operators separating the operands.
expr_or_operator:
- expr
- infix_operator
pattern:
- name_pattern
- tuple_pattern
Expand Down Expand Up @@ -137,6 +143,19 @@ named:
operand: expr
operator: operator

# A flat, unresolved operator sequence such as `a <+> b <+> c`.
#
# Swift's grammar doesn't encode operator precedence, so an operator chain is
# first parsed as a flat list of operands and operators. The parser front-end
# resolves this into structured `binary_expr` trees when it knows the
# operators' precedence (standard-library operators, and operators declared in
# the same file). When it encounters an operator whose precedence it can't
# determine (e.g. one imported from another module), it leaves that chain
# unresolved and emits it here rather than guessing a (possibly wrong)
# structure. The `element`s alternate operands (`expr`) and infix operators.
unresolved_operator_sequence:
element*: expr_or_operator

# Plain assignment
assign_expr:
target: expr_or_pattern
Expand Down
11 changes: 11 additions & 0 deletions unified/extractor/src/languages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ mod swift;
#[allow(dead_code)]
pub mod swift_adapter;

/// Swift front-end parser: shells out to `swift-syntax-parse` and adapts its
/// JSON output via [`swift_adapter`].
///
/// Dormant for now: the runtime Swift front-end is still tree-sitter, so
/// nothing in the binary calls this yet. `allow(dead_code)` for the same
/// binary-crate reason as [`swift_adapter`]; both allows are removed once the
/// runtime switches the Swift front-end to swift-syntax.
#[path = "swift/parse.rs"]
#[allow(dead_code)]
pub mod swift_parse;

/// Shared YEAST output AST schema for all languages.
pub(crate) const OUTPUT_AST_SCHEMA: &str = include_str!("../../ast_types.yml");

Expand Down
95 changes: 54 additions & 41 deletions unified/extractor/src/languages/swift/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
//! in-memory format the CodeQL desugaring rules operate on.
//!
//! The JSON tree is produced by the `swift-syntax-rs` crate's Swift FFI shim
//! (`parse_to_json`). This module is pure Rust (only `yeast` + `serde_json`),
//! so the extractor consumes swift-syntax output without pulling in the Swift
//! toolchain (the JSON is produced out-of-process).
//! (`parse_to_json`). This module needs no Swift toolchain, so the extractor
//! consumes swift-syntax output out-of-process.
//!
//! The mapping mirrors tree-sitter's node model, which is what yeast (and the
//! extractor's rewrite rules) expect:
Expand All @@ -24,31 +23,19 @@

use std::collections::BTreeMap;

use codeql_extractor::extractor::ExtraToken;
use serde_json::Value;
use yeast::schema::Schema;
use yeast::{Ast, Id, NodeContent, Point, Range};

/// A comment (or `unexpectedText`) recovered from the syntax tree's trivia.
/// The result of adapting a swift-syntax JSON tree: the [`yeast::Ast`] plus the
/// comment/`unexpectedText` [`ExtraToken`]s harvested from it (in source order).
///
/// These are collected into a side channel rather than embedded in the
/// [`yeast::Ast`], mirroring how the extractor treats tree-sitter `extra`
/// The extra tokens are collected into a side channel rather than embedded in
/// the [`yeast::Ast`], mirroring how the extractor treats tree-sitter `extra`
/// nodes: they carry a location and text but are not attached to a parent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriviaToken {
/// The trivia kind (e.g. `lineComment`, `blockComment`, `docLineComment`,
/// `docBlockComment`, `unexpectedText`).
pub kind: String,
/// The verbatim source text of the piece (e.g. `// comment`).
pub text: String,
/// The source range the piece occupies.
pub range: Range,
}

/// The result of adapting a swift-syntax JSON tree: the [`yeast::Ast`] plus the
/// comment/`unexpectedText` trivia harvested from it (in source order).
pub struct AdaptedTree {
pub ast: Ast,
pub trivia: Vec<TriviaToken>,
pub extras: Vec<ExtraToken>,
}

/// swift-syntax `TokenKind` cases whose text is *not* determined by the kind
Expand Down Expand Up @@ -170,17 +157,18 @@ fn children_of(value: &Value) -> Vec<&Value> {
/// in the schema on the fly, immediately before the node is created. Children
/// are built first so a parent's field lists reference existing ids. Any
/// comment/`unexpectedText` trivia carried by a token is harvested into
/// `trivia` during the same pass rather than embedded in the tree.
fn build(node: &Value, ast: &mut Ast, trivia: &mut Vec<TriviaToken>) -> Result<Id, String> {
/// `extras` (as [`ExtraToken`]s) during the same pass rather than embedded in
/// the tree.
fn build(node: &Value, ast: &mut Ast, extras: &mut Vec<ExtraToken>) -> Result<Id, String> {
let info = classify(node)?;
collect_trivia(node, trivia);
collect_extras(node, extras);

let mut fields: BTreeMap<u16, Vec<Id>> = BTreeMap::new();
for (field, value) in field_entries(node) {
let field_id = ast.register_field(field);
let mut ids = Vec::new();
for child in children_of(value) {
ids.push(build(child, ast, trivia)?);
ids.push(build(child, ast, extras)?);
}
fields.insert(field_id, ids);
}
Expand All @@ -201,9 +189,10 @@ fn build(node: &Value, ast: &mut Ast, trivia: &mut Vec<TriviaToken>) -> Result<I
}

/// Harvest a token's `leadingTrivia`/`trailingTrivia` pieces (each already
/// filtered to comments/`unexpectedText` upstream) into `out`. Non-token nodes
/// have no trivia keys, so this is a no-op for them.
fn collect_trivia(node: &Value, out: &mut Vec<TriviaToken>) {
/// filtered to comments/`unexpectedText` upstream) into `out` as
/// [`ExtraToken`]s. Non-token nodes have no trivia keys, so this is a no-op for
/// them.
fn collect_extras(node: &Value, out: &mut Vec<ExtraToken>) {
for key in ["leadingTrivia", "trailingTrivia"] {
let Some(Value::Array(pieces)) = node.get(key) else {
continue;
Expand All @@ -220,15 +209,30 @@ fn collect_trivia(node: &Value, out: &mut Vec<TriviaToken>) {
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
out.push(TriviaToken {
kind: kind.to_string(),
out.push(ExtraToken {
kind: trivia_kind_id(kind),
text,
range,
});
}
}
}

/// Map a swift-syntax trivia kind name to the stable integer id stored in an
/// [`ExtraToken`]'s `kind` (and written to the `unified_trivia_tokeninfo`
/// table). The value is opaque to the QL library (which reads only the text),
/// but is kept stable and meaningful.
fn trivia_kind_id(kind: &str) -> usize {
match kind {
"lineComment" => 1,
"blockComment" => 2,
"docLineComment" => 3,
"docBlockComment" => 4,
"unexpectedText" => 5,
_ => 0,
}
}

/// Parse a node's `range` into a [`yeast::Range`].
///
/// The JSON carries, for `start` and `end`, a 0-based UTF-8 file byte `offset`,
Expand Down Expand Up @@ -258,21 +262,29 @@ fn parse_range(node: &Value) -> Option<Range> {
})
}

/// The authoritative swift-syntax input node-types schema, generated from
/// swift-syntax (see the schemagen tool). [`json_to_ast`] seeds every parse
/// with the schema built from this, pre-registering every input kind and field
/// so rule matching never references a name absent from a given file's tree.
const SWIFT_NODE_TYPES: &str = include_str!("../../../swift_node_types.yml");

/// Convert a swift-syntax JSON tree (as produced by [`crate::parse_to_json`])
/// into a [`yeast::Ast`] plus the comment/`unexpectedText` trivia harvested
/// from it. Both are produced in a single traversal.
/// from it. Both are produced in a single traversal. The AST is seeded with the
/// authoritative swift-syntax schema ([`SWIFT_NODE_TYPES`]); the adapter only
/// ever consumes swift-syntax input, so the schema is not a parameter.
pub fn json_to_ast(json: &str) -> Result<AdaptedTree, String> {
let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;

let mut ast = Ast::with_schema(Schema::new());
let mut trivia = Vec::new();
let root_id = build(&root, &mut ast, &mut trivia)?;
let mut ast = Ast::with_schema(yeast::node_types_yaml::schema_from_yaml(SWIFT_NODE_TYPES)?);
let mut extras = Vec::new();
let root_id = build(&root, &mut ast, &mut extras)?;
ast.set_root(root_id);

// Emit trivia in source order (the traversal visits nodes bottom-up).
trivia.sort_by_key(|t| t.range.start_byte);
// Emit extras in source order (the traversal visits nodes bottom-up).
extras.sort_by_key(|t| t.range.start_byte);

Ok(AdaptedTree { ast, trivia })
Ok(AdaptedTree { ast, extras })
}

#[cfg(test)]
Expand Down Expand Up @@ -373,7 +385,7 @@ mod tests {
}

#[test]
fn collects_trivia_into_side_channel() {
fn collects_extras_into_side_channel() {
// A token carrying a trailing line comment in its trivia.
let json = r#"{
"kind": "sourceFile",
Expand All @@ -395,9 +407,10 @@ mod tests {
let adapted = json_to_ast(json).expect("adapter should succeed");

// The comment is in the side channel, with its text and location.
assert_eq!(adapted.trivia.len(), 1);
let comment = &adapted.trivia[0];
assert_eq!(comment.kind, "lineComment");
assert_eq!(adapted.extras.len(), 1);
let comment = &adapted.extras[0];
// `lineComment` maps to extra kind id 1.
assert_eq!(comment.kind, 1);
assert_eq!(comment.text, "// c");
assert_eq!(comment.range.start_byte, 2);
assert_eq!(comment.range.end_byte, 6);
Expand Down
73 changes: 73 additions & 0 deletions unified/extractor/src/languages/swift/parse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! Swift front-end parser: shells out to the separate `swift-syntax-parse`
//! binary (which links swift-syntax) to obtain a JSON syntax tree, then adapts
//! that JSON into a `yeast::Ast` via the pure-Rust [`swift_adapter`] module.
//!
//! Running the parser in a separate process keeps the Swift toolchain out of
//! the extractor's own build: the extractor never links Swift, so working on
//! other (e.g. tree-sitter based) languages needs no Swift toolchain. Each call
//! spawns the parser afresh; a longer-lived parser process could be swapped in
Comment on lines +5 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to suggest that we will only ever have a single unified extractor that then links to (or calls) a plethora of parsers. Is that a worthwhile complexity?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also given our internal discussion regarding linking, I wonder whether this makes sense at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seemed like the easiest solution in the short term, but I don't think it implies that we want all parsers to follow this approach. In particular, for the ones based on tree-sitter, I think it would be nicer to just invoke that parser directly from Rust.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would linking it in directly be more difficult?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it will be more difficult, necessarily, but my worry was that this would put us in a situation where building the unified extractor -- even if you wanted to work on something completely different (e.g. if we convert the Python analysis to use commonAST) -- would result in building the Swift parser (which can be rather slow).

So, to me the cleanest way to enforce the separation seemed to be to just have it completely external, as a separate binary.

Though, having said that I now realise that -- with the current setup -- building the unified extractor still invokes the Swift extractor if the necessary toolchain is present, so I didn't really succeed in this goal.

I'm honestly not sure what the best solution is here. For the short term, it doesn't really matter, since we're only targeting a single language. Once we go to support multiple languages, we may want several build targets, one for each supported language and one for all of them at the same time...

I have no strong feelings about the present solution -- we can easily switch it out for some other approach. However, that is perhaps best left as work in a follow-up PR, as this one is already quite hefty.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again you seem to be suggesting that we will have one glorious unified parser that combines the extraction for a multitude of languages. If we step away from that, then these problems all go away, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My assumption was that this was the architecture we were aiming for, cf. the fact that swift.rs lives in a languages/swift directory.

However, you are right that we could also recast this as a Swift-only extractor (at least, that's how I read your message). In that case, linking directly is probably better.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so we have/had different expectations here. My assumption was that in the end we were still building a separate extractor per language that we would support, based on the underlying shared extractor and yeast code that we have in place. No matter how things are currently laid out.

With one glorious unified extractor, I have issues seeing how this would work if a user would only want to do an extraction for one of the supported languages. This is probably something we need to have a deeper think about.

//! behind this same seam later without touching the extraction pipeline.

use std::io::Write;
use std::process::{Command, Stdio};

use codeql_extractor::extractor::ParsedTree;

use super::swift_adapter;

/// Environment variable naming the `swift-syntax-parse` executable. When unset,
/// `swift-syntax-parse` is looked up on `PATH`.
const PARSE_BIN_ENV: &str = "CODEQL_EXTRACTOR_UNIFIED_SWIFT_SYNTAX_PARSE";

/// Parse Swift `source` into a [`ParsedTree`] (a raw `yeast::Ast` plus
/// side-channel `extra` tokens), ready to be desugared via `run_from_ast`.
pub fn parse(source: &[u8]) -> Result<ParsedTree, String> {
let source =
std::str::from_utf8(source).map_err(|e| format!("Swift source is not valid UTF-8: {e}"))?;
let json = run_parser(source)?;
let mut adapted = swift_adapter::json_to_ast(&json)?;
adapted.ast.set_source(source.as_bytes().to_vec());
Ok(ParsedTree {
ast: adapted.ast,
extras: adapted.extras,
})
}

/// The `swift-syntax-parse` executable to invoke.
fn parse_bin() -> String {
std::env::var(PARSE_BIN_ENV).unwrap_or_else(|_| "swift-syntax-parse".to_string())
Comment thread
tausbn marked this conversation as resolved.
Outdated
}

/// Run the external parser, feeding `source` on stdin and returning its JSON
/// stdout.
fn run_parser(source: &str) -> Result<String, String> {
let bin = parse_bin();
let mut child = Command::new(&bin)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to spawn Swift parser `{bin}`: {e}"))?;

// The parser reads all of stdin before writing any stdout, so writing the
// whole source and then closing stdin (by dropping it) cannot deadlock.
child
.stdin
.take()
.expect("child stdin was piped")
.write_all(source.as_bytes())
.map_err(|e| format!("failed to write source to Swift parser `{bin}`: {e}"))?;

let output = child
.wait_with_output()
.map_err(|e| format!("failed to run Swift parser `{bin}`: {e}"))?;
if !output.status.success() {
return Err(format!(
"Swift parser `{bin}` failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
String::from_utf8(output.stdout)
.map_err(|e| format!("Swift parser produced non-UTF-8 output: {e}"))
}
Loading