fix: review follow-ups for #5 and the Swift lexical preview #8

Merged
trevon merged 9 commits from fix/review-followups into main 2026-09-25 13:33:41 +00:00
2 changed files with 179 additions and 1 deletions
Showing only changes of commit c3c7c6c3f4 - Show all commits

fix(swift): give the lexical preview Swift's forms and mask block comments

Swift ran through the C-style lexical patterns, which never recognize
`func`. On a small file (`func realTarget()`, `func ghost()`, a class
method `caller` that calls `realTarget()` below a `/* ghost() */`
comment) the preview reported:

  a.swift -> ghost       (a.swift:2 declaration, a.swift:5 in a comment)
  a.swift -> caller      (its declaration line)
  a.swift -> realTarget  (its declaration line)

and missed the real call. Now:

  caller -> realTarget   (a.swift:7)

- Swift gets its own patterns: `func` declarations (with attributes and
  modifiers), class/struct/enum/protocol/actor/extension declarations
  (`class func` / `class var` are members, not classes), and imports.
- Block comments are masked across lines, nested as Swift allows, and a
  `/*` inside a string literal does not open one. The masker is salvaged
  from the unmerged language-parity branch (543bafc).
- Calls are attributed to their enclosing function through the existing
  brace-scoped scopes, now that functions are recognized.

Test: tests/swift_preview.rs (functions and a class recognized, the call
attributed to `caller`, nothing from comments, nothing from the file
node); it failed before this change.
trvon 2026-09-24 21:00:59 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -452,10 +452,18 @@ impl ScanState {
let patterns = Patterns::new(language);
let mut scopes: Vec<Scope> = Vec::new();
let mut brace_depth = 0_i32;
let mut comment_depth = 0_usize;
for (index, raw_line) in content.lines().enumerate() {
let line_number = index + 1;
let line = strip_line_comment(raw_line, language);
// Block comments (nested, as Swift allows) span lines; without masking,
// a call inside `/* ... */` became an edge.
let masked = if language == "python" {
raw_line.to_owned()
} else {
mask_block_comments(raw_line, &mut comment_depth)
};
let line = strip_line_comment(&masked, language);
let call_line = mask_quoted_strings(line);
let indent = raw_line.len() - raw_line.trim_start().len();
if language == "python" {
@ -479,6 +487,7 @@ impl ScanState {
let mut class_name = None;
if let Some(captures) = patterns.class.captures(line)
&& let Some(name) = captures.name("name")
&& !(language == "swift" && is_swift_member_keyword(name.as_str()))
{
class_name = Some(name.as_str().to_owned());
let owner_id = scopes
@ -2465,6 +2474,14 @@ struct Patterns {
impl Patterns {
fn new(language: &str) -> Self {
let (class, function, import) = match language {
// Swift's own forms: `func name(`, type declarations with modifiers and
// attributes, and module imports. It used to run through the C patterns,
// which never recognized `func`.
"swift" => (
r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:public|private|internal|fileprivate|open|final|indirect)\s+)*(?:class|struct|enum|protocol|actor|extension)\s+(?P<name>[A-Za-z_]\w*)(?:\s*:\s*(?P<base>[A-Za-z_][\w.]*))?",
r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:public|private|internal|fileprivate|open|static|class|override|mutating|nonmutating|final|required|convenience|dynamic|nonisolated)\s+)*func\s+(?P<name>[A-Za-z_]\w*)",
r"^\s*(?:@\w+\s+)?import\s+(?:(?:typealias|struct|class|enum|protocol|let|var|func)\s+)?(?P<name>[A-Za-z_][\w.]*)",
),
"python" => (
r"^\s*class\s+(?P<name>[A-Za-z_]\w*)(?:\((?P<base>[A-Za-z_][\w.]*)\))?",
r"^\s*(?:async\s+)?def\s+(?P<name>[A-Za-z_]\w*)\s*\(",
@ -2509,6 +2526,69 @@ fn strip_line_comment<'a>(line: &'a str, language: &str) -> &'a str {
}
}
/// `class func` / `class var` declare members, not a class named `func`.
fn is_swift_member_keyword(name: &str) -> bool {
matches!(
name,
"func" | "var" | "let" | "subscript" | "init" | "deinit" | "case" | "typealias"
)
}
/// Mask `/* ... */` comments (nested, as Swift allows) with spaces, carrying the
/// open-comment depth across lines. Quoted strings are left intact, so a `/*`
/// inside a string (`"http://x/*y"`) does not open a comment.
fn mask_block_comments(line: &str, depth: &mut usize) -> String {
let chars: Vec<char> = line.chars().collect();
let mut result = String::with_capacity(line.len());
let mut quote = None;
let mut escaped = false;
let mut index = 0;
while index < chars.len() {
let ch = chars[index];
let next = chars.get(index + 1).copied();
if *depth > 0 {
if ch == '/' && next == Some('*') {
*depth += 1;
result.push_str(" ");
index += 2;
} else if ch == '*' && next == Some('/') {
*depth -= 1;
result.push_str(" ");
index += 2;
} else {
result.push(' ');
index += 1;
}
} else if let Some(active) = quote {
result.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == active {
quote = None;
}
index += 1;
} else if ch == '"' && chars[index + 1..].contains(&'"') {
quote = Some(ch);
result.push(ch);
index += 1;
} else if ch == '/' && next == Some('/') {
// The rest is a line comment; strip_line_comment removes it.
result.extend(&chars[index..]);
break;
} else if ch == '/' && next == Some('*') {
*depth = 1;
result.push_str(" ");
index += 2;
} else {
result.push(ch);
index += 1;
}
}
result
}
fn brace_delta(line: &str) -> i32 {
line.chars().fold(0, |depth, ch| match ch {
'{' => depth + 1,

98
tests/swift_preview.rs Normal file
View file

@ -0,0 +1,98 @@
use brick::model::{Building, EdgeKind, NodeKind};
use brick::scan;
use std::fs;
fn name_of<'a>(model: &'a Building, id: &str) -> &'a str {
model
.nodes
.iter()
.find(|node| node.id == id)
.map_or("", |node| node.name.as_str())
}
fn calls(model: &Building) -> Vec<(String, String, NodeKind)> {
model
.edges
.iter()
.filter(|edge| edge.kind == EdgeKind::Calls)
.map(|edge| {
let source_kind = model
.nodes
.iter()
.find(|node| node.id == edge.source)
.map(|node| node.kind)
.unwrap();
(
name_of(model, &edge.source).to_owned(),
name_of(model, &edge.target).to_owned(),
source_kind,
)
})
.collect()
}
#[test]
fn swift_preview_declares_functions_and_ignores_comments() {
// Swift ran through the C-style lexical patterns: `func` declarations were
// never functions, each declaration line became a call from the file, calls
// inside (nested) block comments became edges, and real calls were attributed
// to the file instead of their function.
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("a.swift"),
r#"import Foundation
func realTarget() -> Int { return 1 }
func ghost() -> Int { return 2 }
final class Worker: NSObject {
let url = "http://example.com/*not-a-comment"
func caller() -> Int {
/* disabled:
ghost()
/* nested: ghost() */
still disabled: ghost()
*/
return realTarget()
}
}
"#,
)
.unwrap();
let model = scan::scan(dir.path()).unwrap();
let functions: Vec<&str> = model
.nodes
.iter()
.filter(|node| node.kind == NodeKind::Function)
.map(|node| node.name.as_str())
.collect();
for name in ["realTarget", "ghost", "caller"] {
assert!(
functions.contains(&name),
"{name} not a function: {functions:?}"
);
}
assert!(
model
.nodes
.iter()
.any(|node| node.kind == NodeKind::Class && node.name == "Worker")
);
let calls = calls(&model);
assert!(
calls.iter().any(|(source, target, kind)| source == "caller"
&& target == "realTarget"
&& *kind == NodeKind::Function),
"{calls:?}"
);
assert!(
!calls.iter().any(|(_, target, _)| target == "ghost"),
"a call inside a block comment became an edge: {calls:?}"
);
assert!(
!calls.iter().any(|(_, _, kind)| *kind == NodeKind::File),
"a declaration or statement was attributed to the file: {calls:?}"
);
}