feat: establish first-class lexical language parity #2
11 changed files with 177 additions and 34 deletions
feat(scan): promote JavaScript and TypeScript forms
commit
02ac22b8de
|
|
@ -22,8 +22,8 @@ frontend; Brick does not guess that ownership.
|
|||
| Rust | Structs, enums, traits, ordinary/async/const/unsafe/extern functions, simple inherent/trait impl ownership, ordinary/turbofish/macro calls, braced `use` resolution, and raw-string masking | module ownership, unions/type aliases, nested generic impl headers, glob imports, and macro definitions | first-class target |
|
||||
| C | Distinct `.c` identity, includes, structs/unions/enums, narrow function forms, and ordinary calls | anonymous typedef aggregates, function pointers, C23 attributes, macros, and multiline declarations | first-class target |
|
||||
| C++ | Classes/structs/unions/enums, `final`, constructors/destructors/operators, override-qualified methods, multiple inheritance, ordinary calls, and local include resolution | namespaces, overload disambiguation, lambdas, concepts/requires, complex template bases, and multiline declarations | first-class target |
|
||||
| JavaScript | Named functions, simple classes/extends, ES imports, and call-shaped expressions | methods, arrows/function expressions, CommonJS, dynamic imports, re-exports, decorators, and template literals | first-class target |
|
||||
| TypeScript | JavaScript basics plus TypeScript file extensions and simple imports | interfaces, type aliases, enums/namespaces, generic/abstract declarations, overloads, decorators, and path mappings | first-class target |
|
||||
| JavaScript | Default/exported classes, inheritance, owned methods/constructors, named functions, arrows, ES imports/re-exports, CommonJS `require`, dynamic imports, ordinary calls, and multiline template masking | function expressions, computed methods, decorators, JSX structure, package exports, and type-aware member calls | first-class target |
|
||||
| TypeScript | JavaScript forms plus interfaces/enums/namespaces, generic and abstract classes/functions/methods, `implements`, type imports/re-exports, and typed arrows | type aliases, overload declarations, decorators, declaration merging, TSX structure, and path mappings | first-class target |
|
||||
| Python | Basic classes, functions/async functions, imports, calls, and indentation scopes | multiple bases, qualified ownership, decorators, multiline strings, relative/dynamic imports, and complete multi-import handling | secondary |
|
||||
| Swift | File recognition and incidental matching of some class/function syntax | no dedicated patterns; imports, protocols, actors, enums, extensions, async/generic forms, and ownership are incomplete | secondary |
|
||||
|
||||
|
|
|
|||
99
src/scan.rs
99
src/scan.rs
|
|
@ -242,6 +242,7 @@ impl ScanState {
|
|||
let mut scopes: Vec<Scope> = Vec::new();
|
||||
let mut brace_depth = 0_i32;
|
||||
let mut rust_raw_end: Option<String> = None;
|
||||
let mut javascript_template_open = false;
|
||||
|
||||
for (index, raw_line) in content.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
|
|
@ -251,7 +252,12 @@ impl ScanState {
|
|||
}
|
||||
continue;
|
||||
}
|
||||
let code_line = if language == "rust" {
|
||||
let template_masked;
|
||||
let code_line = if matches!(language, "javascript" | "typescript") {
|
||||
template_masked =
|
||||
mask_javascript_templates(raw_line, &mut javascript_template_open);
|
||||
template_masked.as_str()
|
||||
} else if language == "rust" {
|
||||
if let Some((start, content_start, delimiter)) = rust_raw_string_start(raw_line) {
|
||||
if !raw_line[content_start..].contains(&delimiter) {
|
||||
rust_raw_end = Some(delimiter);
|
||||
|
|
@ -378,7 +384,11 @@ impl ScanState {
|
|||
.map(|scope| scope.id.clone())
|
||||
.unwrap_or_else(|| file_id.into());
|
||||
|
||||
for captures in patterns.import.captures_iter(line) {
|
||||
for captures in patterns
|
||||
.import
|
||||
.iter()
|
||||
.flat_map(|pattern| pattern.captures_iter(line))
|
||||
{
|
||||
if let Some(target) = captures.name("name").or_else(|| captures.name("name2")) {
|
||||
self.pending.push(PendingRelationship {
|
||||
source: file_id.into(),
|
||||
|
|
@ -623,7 +633,7 @@ struct Scope {
|
|||
struct Patterns {
|
||||
class: Regex,
|
||||
function: Vec<Regex>,
|
||||
import: Regex,
|
||||
import: Vec<Regex>,
|
||||
call: Vec<Regex>,
|
||||
relationships: Option<Regex>,
|
||||
enclosing_scope: Option<Regex>,
|
||||
|
|
@ -631,11 +641,13 @@ struct Patterns {
|
|||
|
||||
impl Patterns {
|
||||
fn new(language: &str) -> Self {
|
||||
let (class, functions, import, relationships) = match language {
|
||||
let (class, functions, imports, relationships) = match language {
|
||||
"python" => (
|
||||
r"^\s*class\s+(?P<name>[A-Za-z_]\w*)(?:\((?P<base>[A-Za-z_][\w.]*)\))?",
|
||||
vec![r"^\s*(?:async\s+)?def\s+(?P<name>[A-Za-z_]\w*)\s*\("],
|
||||
r"^\s*(?:from\s+(?P<name>[A-Za-z_][\w.]*)\s+import|import\s+(?P<name2>[A-Za-z_][\w.]*))",
|
||||
vec![
|
||||
r"^\s*(?:from\s+(?P<name>[A-Za-z_][\w.]*)\s+import|import\s+(?P<name2>[A-Za-z_][\w.]*))",
|
||||
],
|
||||
None,
|
||||
),
|
||||
"rust" => (
|
||||
|
|
@ -643,13 +655,13 @@ impl Patterns {
|
|||
vec![
|
||||
r#"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:(?:const|async|unsafe)\s+)*(?:extern(?:\s+"[^"]+")?\s+)?fn\s+(?P<name>[A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*\("#,
|
||||
],
|
||||
r"^\s*use\s+(?P<name>[A-Za-z_][\w:]*)",
|
||||
vec![r"^\s*use\s+(?P<name>[A-Za-z_][\w:]*)"],
|
||||
None,
|
||||
),
|
||||
"c" => (
|
||||
r"^\s*(?:typedef\s+)?(?:struct|union|enum)\s+(?P<name>[A-Za-z_]\w*)",
|
||||
vec![r"^\s*(?:[\w*&]+\s+)+(?P<name>[A-Za-z_]\w*)\s*\([^;{}]*\)\s*\{?\s*$"],
|
||||
r#"^\s*#\s*include\s*[<\"](?P<name>[^>\"]+)[>\"]"#,
|
||||
vec[>\"]"#],
|
||||
None,
|
||||
),
|
||||
"cpp" => (
|
||||
|
|
@ -659,14 +671,30 @@ impl Patterns {
|
|||
r"^\s*(?P<name>~?[A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?::[^{}]*)?\s*(?:noexcept\s*)?(?:\{\s*\}|\{|=\s*(?:default|delete)\s*;)\s*$",
|
||||
r"^\s*(?:[\w:<>,*&]+\s+)+(?P<name>operator\s*(?:[^\s(]+|\(\)))\s*\([^;{}]*\)\s*(?:const\s*)?(?:noexcept\s*)?(?:\{|=\s*(?:default|delete)\s*;)\s*$",
|
||||
],
|
||||
r#"^\s*#\s*include\s*[<\"](?P<name>[^>\"]+)[>\"]"#,
|
||||
vec[>\"]"#],
|
||||
Some(r":\s*(?P<names>[^\{]+)"),
|
||||
),
|
||||
"javascript" | "typescript" => (
|
||||
r"^\s*(?:export\s+)?class\s+(?P<name>[A-Za-z_$][\w$]*)(?:\s+extends\s+(?P<base>[A-Za-z_$][\w$]*))?",
|
||||
vec![r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P<name>[A-Za-z_$][\w$]*)\s*\("],
|
||||
r#"^\s*import(?:[^'\"]*from\s*)?['\"](?P<name>[^'\"]+)['\"]"#,
|
||||
None,
|
||||
"javascript" => (
|
||||
r"^\s*(?:export\s+)?(?:default\s+)?class\s+(?P<name>[A-Za-z_$][\w$]*)(?:\s+extends\s+(?P<base>[A-Za-z_$][\w$]*))?",
|
||||
vec![
|
||||
r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(?P<name>[A-Za-z_$][\w$]*)\s*\(",
|
||||
r"^\s*(?:(?:static|async|get|set)\s+)*(?P<name>constructor|[A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{",
|
||||
r"^\s*(?:export\s+)?(?:const|let|var)\s+(?P<name>[A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>",
|
||||
],
|
||||
javascript_import_patterns(),
|
||||
Some(r"\bextends\s+(?P<names>[A-Za-z_$][\w$]*)"),
|
||||
),
|
||||
"typescript" => (
|
||||
r"^\s*(?:export\s+)?(?:default\s+)?(?:(?:abstract|declare)\s+)?(?:class|interface|enum|namespace)\s+(?P<name>[A-Za-z_$][\w$]*)(?:\s*<[^>{}]+>)?",
|
||||
vec![
|
||||
r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(?P<name>[A-Za-z_$][\w$]*)\s*(?:<[^>{}]+>)?\s*\(",
|
||||
r"^\s*(?:(?:public|protected|private|static|abstract|async|get|set|readonly|override)\s+)*(?P<name>constructor|[A-Za-z_$][\w$]*)\s*(?:<[^>{}]+>)?\s*\([^)]*\)\s*(?::[^;{]+)?\s*\{",
|
||||
r"^\s*(?:export\s+)?(?:const|let|var)\s+(?P<name>[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>{}]+>\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=>",
|
||||
],
|
||||
javascript_import_patterns(),
|
||||
Some(
|
||||
r"\b(?:extends|implements)\s+(?P<names>[A-Za-z_$][\w$]*(?:<[^>{}]+>)?(?:\s*,\s*[A-Za-z_$][\w$]*(?:<[^>{}]+>)?)*)",
|
||||
),
|
||||
),
|
||||
"dart" => (
|
||||
r"^\s*(?:(?:abstract|base|final|interface|sealed)\s+)?(?:class|mixin|enum|extension(?:\s+type)?)\s+(?P<name>[A-Za-z_]\w*)(?:\s+extends\s+(?P<base>[A-Za-z_]\w*))?",
|
||||
|
|
@ -676,7 +704,7 @@ impl Patterns {
|
|||
r"^\s*(?:(?:external|static|abstract|covariant|late)\s+)*(?:(?:[A-Za-z_]\w*(?:<[^>{};()]+>)?[?!]?)\s+)?get\s+(?P<name>[A-Za-z_]\w*)\s*(?:=>|\{)",
|
||||
r"^\s*(?:(?:external|static|abstract|covariant|late)\s+)*set\s+(?P<name>[A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?:=>|\{|;)",
|
||||
],
|
||||
r#"^\s*(?:import|export|part)\s+['\"](?P<name>[^'\"]+)['\"]"#,
|
||||
vec['\"]"#],
|
||||
Some(
|
||||
r"\b(?:extends|with|implements|on)\s+(?P<names>[A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)",
|
||||
),
|
||||
|
|
@ -686,7 +714,7 @@ impl Patterns {
|
|||
vec![
|
||||
r"^\s*(?:[\w:<>,*&]+\s+)+(?P<name>[A-Za-z_]\w*(?:::\w+)*)\s*\([^;{}]*\)\s*(?:const\s*)?(?:noexcept\s*)?\{?\s*$",
|
||||
],
|
||||
r#"^\s*#\s*include\s*[<\"](?P<name>[^>\"]+)[>\"]"#,
|
||||
vec[>\"]"#],
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
|
@ -703,7 +731,10 @@ impl Patterns {
|
|||
.into_iter()
|
||||
.map(|pattern| Regex::new(pattern).expect("function regex is static"))
|
||||
.collect(),
|
||||
import: Regex::new(import).expect("import regex is static"),
|
||||
import: imports
|
||||
.into_iter()
|
||||
.map(|pattern| Regex::new(pattern).expect("import regex is static"))
|
||||
.collect(),
|
||||
call: calls
|
||||
.into_iter()
|
||||
.map(|pattern| Regex::new(pattern).expect("call regex is static"))
|
||||
|
|
@ -720,6 +751,15 @@ impl Patterns {
|
|||
}
|
||||
}
|
||||
|
||||
fn javascript_import_patterns() -> Vec<&'static str> {
|
||||
vec![
|
||||
r#"^\s*import(?:\s+type)?(?:[^'\"]*from\s*)?['\"](?P<name>[^'\"]+)['\"]"#,
|
||||
r#"^\s*export(?:\s+type)?\s+[^'\"]*from\s*['\"](?P<name>[^'\"]+)['\"]"#,
|
||||
r#"\brequire\s*\(\s*['\"](?P<name>[^'\"]+)['\"]\s*\)"#,
|
||||
r#"\bimport\s*\(\s*['\"](?P<name>[^'\"]+)['\"]\s*\)"#,
|
||||
]
|
||||
}
|
||||
|
||||
fn language_for(path: &Path) -> Option<&'static str> {
|
||||
match path.extension()?.to_str()? {
|
||||
"rs" => Some("rust"),
|
||||
|
|
@ -775,6 +815,29 @@ fn mask_quoted_strings(line: &str) -> String {
|
|||
result
|
||||
}
|
||||
|
||||
fn mask_javascript_templates(line: &str, open: &mut bool) -> String {
|
||||
let mut result = String::with_capacity(line.len());
|
||||
let mut escaped = false;
|
||||
for ch in line.chars() {
|
||||
if *open {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '`' {
|
||||
*open = false;
|
||||
}
|
||||
result.push(' ');
|
||||
} else if ch == '`' {
|
||||
*open = true;
|
||||
result.push(' ');
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn rust_raw_string_start(line: &str) -> Option<(usize, usize, String)> {
|
||||
let bytes = line.as_bytes();
|
||||
for start in 0..bytes.len() {
|
||||
|
|
@ -820,6 +883,8 @@ fn clean_relationship_target(value: &str, language: &str) -> String {
|
|||
.filter(|part| !matches!(*part, "public" | "protected" | "private" | "virtual"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else if matches!(language, "javascript" | "typescript") {
|
||||
target.split('<').next().unwrap_or(&target).trim().into()
|
||||
} else {
|
||||
target
|
||||
}
|
||||
|
|
@ -885,6 +950,8 @@ fn is_keyword(name: &str) -> bool {
|
|||
| "assert"
|
||||
| "assert_eq"
|
||||
| "println"
|
||||
| "import"
|
||||
| "require"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
1
tests/fixtures/languages/javascript/api.js
vendored
Normal file
1
tests/fixtures/languages/javascript/api.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export function api() {}
|
||||
1
tests/fixtures/languages/javascript/base.js
vendored
Normal file
1
tests/fixtures/languages/javascript/base.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export class Base {}
|
||||
19
tests/fixtures/languages/javascript/basic.js
vendored
19
tests/fixtures/languages/javascript/basic.js
vendored
|
|
@ -1,9 +1,22 @@
|
|||
import { Base } from './base.js';
|
||||
export { api } from './api.js';
|
||||
|
||||
class Worker extends Base {}
|
||||
const legacy = require('./legacy.cjs');
|
||||
const lazy = import('./lazy.js');
|
||||
|
||||
function start() {
|
||||
helper();
|
||||
export default class Worker extends Base {
|
||||
constructor() {}
|
||||
|
||||
start() {
|
||||
helper();
|
||||
}
|
||||
}
|
||||
|
||||
const arrow = () => helper();
|
||||
|
||||
function helper() {}
|
||||
|
||||
const ignored = `
|
||||
class Phantom extends Ghost {}
|
||||
function fake() { phantomCall(); }
|
||||
`;
|
||||
|
|
|
|||
1
tests/fixtures/languages/javascript/lazy.js
vendored
Normal file
1
tests/fixtures/languages/javascript/lazy.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export function lazy() {}
|
||||
1
tests/fixtures/languages/javascript/legacy.cjs
vendored
Normal file
1
tests/fixtures/languages/javascript/legacy.cjs
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
module.exports = {};
|
||||
1
tests/fixtures/languages/typescript/base.ts
vendored
Normal file
1
tests/fixtures/languages/typescript/base.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export class Base<T> {}
|
||||
26
tests/fixtures/languages/typescript/basic.ts
vendored
26
tests/fixtures/languages/typescript/basic.ts
vendored
|
|
@ -1,9 +1,27 @@
|
|||
import { Base } from './base.js';
|
||||
import type { Base } from './base.js';
|
||||
export type { Contract } from './contract.js';
|
||||
|
||||
class Worker extends Base {}
|
||||
interface Runnable {
|
||||
run<T>(value: T): void;
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
helper();
|
||||
export abstract class Worker<T> extends Base<T> implements Runnable {
|
||||
constructor() {}
|
||||
|
||||
run<U>(value: U): void {
|
||||
helper();
|
||||
}
|
||||
}
|
||||
|
||||
const arrow = <T>(value: T): T => identity(value);
|
||||
|
||||
function identity<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
function helper(): void {}
|
||||
|
||||
const ignored = `
|
||||
interface Phantom {}
|
||||
function fake(): void { phantomCall(); }
|
||||
`;
|
||||
|
|
|
|||
1
tests/fixtures/languages/typescript/contract.ts
vendored
Normal file
1
tests/fixtures/languages/typescript/contract.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export interface Contract {}
|
||||
|
|
@ -61,20 +61,20 @@ fn first_class_language_fixtures_have_stable_baseline_evidence() {
|
|||
FixtureCase {
|
||||
directory: "javascript",
|
||||
language: "javascript",
|
||||
classes: &["Worker"],
|
||||
functions: &["helper", "start"],
|
||||
imports: 1,
|
||||
classes: &["Base", "Worker"],
|
||||
functions: &["api", "arrow", "constructor", "helper", "lazy", "start"],
|
||||
imports: 4,
|
||||
inherits: 1,
|
||||
calls: 1,
|
||||
calls: 2,
|
||||
},
|
||||
FixtureCase {
|
||||
directory: "typescript",
|
||||
language: "typescript",
|
||||
classes: &["Worker"],
|
||||
functions: &["helper", "start"],
|
||||
imports: 1,
|
||||
inherits: 1,
|
||||
calls: 1,
|
||||
classes: &["Base", "Contract", "Runnable", "Worker"],
|
||||
functions: &["arrow", "constructor", "helper", "identity", "run"],
|
||||
imports: 2,
|
||||
inherits: 2,
|
||||
calls: 2,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -141,6 +141,45 @@ fn cpp_fixture_models_member_ownership_without_declaration_calls() {
|
|||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javascript_and_typescript_model_members_without_template_phantoms() {
|
||||
for directory in ["javascript", "typescript"] {
|
||||
let building = scan(&fixture_root(directory)).unwrap();
|
||||
let worker = building
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.kind == NodeKind::Class && node.name == "Worker")
|
||||
.unwrap();
|
||||
let member_names: Vec<_> = building
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| {
|
||||
node.kind == NodeKind::Function && node.parent.as_ref() == Some(&worker.id)
|
||||
})
|
||||
.map(|node| node.name.as_str())
|
||||
.collect();
|
||||
|
||||
assert_eq!(member_names.len(), 2, "{directory} member ownership");
|
||||
assert!(member_names.contains(&"constructor"));
|
||||
assert!(
|
||||
!building
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|node| { matches!(node.name.as_str(), "Phantom" | "fake" | "phantomCall") })
|
||||
);
|
||||
assert!(
|
||||
building
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|edge| edge.kind == EdgeKind::Imports)
|
||||
.all(|edge| building
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|node| { node.id == edge.target && node.kind == NodeKind::File }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_fixture(case: FixtureCase) {
|
||||
let root = fixture_root(case.directory);
|
||||
let building = scan(&root).unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue