Skip to content

Commit d209b97

Browse files
committed
Faster workspace symbol search
1 parent c749daa commit d209b97

4 files changed

Lines changed: 83 additions & 40 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5858
- **Faster `assert()`/type-guard narrowing during the forward walk.** Every statement used to build a fresh resolution context (including a scope clone) for each in-scope variable to check whether it was an `assert()` or `@phpstan-assert`/`@psalm-assert` call, even for statements that could never be one. Non-call statements now skip that work entirely, cutting a measurable share of the walk on methods with many locals and many statements.
5959
- **Faster diagnostics on method/function calls that resolve to no concrete class.** Checking whether such a call's result was actually a bare `object`/`?object` (still valid for member access) used to re-resolve the callee's whole receiver chain and method signature a second time from scratch. That check now reuses the resolution already performed, roughly halving diagnostics time on files with many unresolved or missing-method call chains.
6060
- **Argument checking no longer slows down quadratically with file size.** The argument-count and argument-type checks know the byte offset of every call they inspect, but used to convert it into an editor line/column position and immediately back again before looking up the called function. Each of those conversions re-read the file from the beginning to count characters, so the cost of checking one call grew with the size of the file around it and a file with thousands of calls spent nearly all its time on offset arithmetic. The offset is now used directly. On a 370 KB file containing 2200 calls the two checks together drop from 13.7 seconds to 0.2, taking the whole file from 16.7 seconds to 3.4, and analysing the project it belongs to is close to three times faster.
61+
- **Faster workspace symbol search.** Matching a symbol against the "Go to Symbol in Workspace" query no longer allocates a lowercased copy of every class, method, property, constant, and function name in the project on each keystroke; matching is now done byte-wise in place for the common case of ASCII identifiers.
6162

6263
### Removed
6364

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,6 @@ unlikely to move the needle for most users.
208208
| P18 | [Subtype result caching](todo/performance.md#p18-subtype-result-caching) (per-request HashMap for hierarchy walks) | Medium | Low |
209209
| P20 | [Content-hash gated resolution cache persistence](todo/performance.md#p20-content-hash-gated-resolution-cache-persistence) | Medium | Medium |
210210
| P21 | [Offset-shifting for cached diagnostics on partial edits](todo/performance.md#p21-offset-shifting-for-cached-diagnostics-on-partial-edits) | Medium | Medium |
211-
| P23 | [`workspace/symbol` lowercases every symbol name per request](todo/performance.md#p23-workspacesymbol-allocates-a-lowercase-copy-of-every-symbol-name-per-request) | Low-Medium | Low |
212211
| | **[Indexing](todo/indexing.md)** | | |
213212
| X3 | Completion item detail on demand (`completionItem/resolve`) | Medium | Medium |
214213
| X7 | [Recency tracking](todo/indexing.md#x7-recency-tracking) | Medium | Medium |

docs/todo/performance.md

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -561,37 +561,6 @@ the blast radius of an edit.
561561

562562
---
563563

564-
## P23. `workspace/symbol` allocates a lowercase copy of every symbol name per request
565-
566-
**Impact: Low-Medium · Effort: Low**
567-
568-
`match_tier` (`src/workspace_symbols.rs:64-72`) calls
569-
`name.to_lowercase()` on every candidate symbol, and each symbol
570-
is tested against up to two or three name forms (FQN, short name,
571-
display name — call sites at lines 144, 232, 318, 401, 471). A
572-
`workspace/symbol` request walks every class, method, property,
573-
constant, and function in `uri_classes_index` and
574-
`global_functions`, so each keystroke in the editor's symbol
575-
picker performs O(total symbols × name length) heap allocations
576-
just for case folding, then throws them away.
577-
578-
### Fix
579-
580-
Match case-insensitively without allocating: a byte-wise
581-
`eq_ignore_ascii_case`-style prefix/substring scan (PHP
582-
identifiers are ASCII; a non-ASCII fallback can keep the old
583-
path), or store a pre-lowercased name alongside each symbol if
584-
the tiering logic needs real substring search. Note B25
585-
(case-insensitive index keys) will make lowercased names
586-
available on the index side anyway — implementing that first
587-
makes this nearly free.
588-
589-
Related: full background indexing already populates a dedicated
590-
workspace-symbol index; this fix is independent and worth taking
591-
regardless since it is a few lines.
592-
593-
---
594-
595564
## P46. `mago-phpdoc-syntax` cannot parse `@method static (…) name()`
596565

597566
**Impact: Low · Effort: Low (upstream)**

src/workspace_symbols.rs

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,22 +61,65 @@ struct RankedSymbol {
6161
///
6262
/// `query_lower` must already be lowercased. Returns `None` when
6363
/// there is no match at all.
64+
///
65+
/// PHP identifiers are ASCII, so the common case is matched byte-wise
66+
/// with `eq_ignore_ascii_case` and never allocates. Non-ASCII names
67+
/// (e.g. a class in a file using non-ASCII identifiers) fall back to
68+
/// the allocating `to_lowercase()` path.
6469
fn match_tier(name: &str, query_lower: &str) -> Option<MatchTier> {
6570
if query_lower.is_empty() {
6671
// Empty query matches everything at the lowest tier so that
6772
// alphabetical ordering is the only tiebreaker.
6873
return Some(MatchTier::Substring);
6974
}
70-
let name_lower = name.to_lowercase();
71-
if name_lower == query_lower {
72-
Some(MatchTier::Exact)
73-
} else if name_lower.starts_with(query_lower) {
74-
Some(MatchTier::Prefix)
75-
} else if name_lower.contains(query_lower) {
76-
Some(MatchTier::Substring)
75+
76+
if name.is_ascii() {
77+
if ascii_eq_ignore_case(name, query_lower) {
78+
Some(MatchTier::Exact)
79+
} else if ascii_starts_with_ignore_case(name, query_lower) {
80+
Some(MatchTier::Prefix)
81+
} else if ascii_contains_ignore_case(name, query_lower) {
82+
Some(MatchTier::Substring)
83+
} else {
84+
None
85+
}
7786
} else {
78-
None
87+
let name_lower = name.to_lowercase();
88+
if name_lower == query_lower {
89+
Some(MatchTier::Exact)
90+
} else if name_lower.starts_with(query_lower) {
91+
Some(MatchTier::Prefix)
92+
} else if name_lower.contains(query_lower) {
93+
Some(MatchTier::Substring)
94+
} else {
95+
None
96+
}
97+
}
98+
}
99+
100+
/// Case-insensitive equality for ASCII strings, without allocating.
101+
fn ascii_eq_ignore_case(s: &str, lower: &str) -> bool {
102+
s.as_bytes().eq_ignore_ascii_case(lower.as_bytes())
103+
}
104+
105+
/// Case-insensitive prefix check for ASCII strings, without allocating.
106+
fn ascii_starts_with_ignore_case(s: &str, prefix_lower: &str) -> bool {
107+
s.len() >= prefix_lower.len()
108+
&& s.as_bytes()[..prefix_lower.len()].eq_ignore_ascii_case(prefix_lower.as_bytes())
109+
}
110+
111+
/// Case-insensitive substring check for ASCII strings, without allocating.
112+
fn ascii_contains_ignore_case(s: &str, needle_lower: &str) -> bool {
113+
let s = s.as_bytes();
114+
let needle = needle_lower.as_bytes();
115+
if needle.is_empty() {
116+
return true;
117+
}
118+
if s.len() < needle.len() {
119+
return false;
79120
}
121+
s.windows(needle.len())
122+
.any(|w| w.eq_ignore_ascii_case(needle))
80123
}
81124

82125
/// Extract the short name from a symbol name for relevance ranking.
@@ -606,4 +649,35 @@ mod tests {
606649
assert!(MatchTier::Exact < MatchTier::Prefix);
607650
assert!(MatchTier::Prefix < MatchTier::Substring);
608651
}
652+
653+
#[test]
654+
fn match_tier_non_ascii_name_falls_back() {
655+
// Non-ASCII names (e.g. Turkish "İ") don't lowercase byte-for-byte,
656+
// so they must go through the `to_lowercase()` fallback path.
657+
assert_eq!(match_tier("Naïve", "naïve"), Some(MatchTier::Exact));
658+
assert_eq!(match_tier("NaïveBar", "naïve"), Some(MatchTier::Prefix));
659+
assert_eq!(
660+
match_tier("MyNaïveBar", "naïve"),
661+
Some(MatchTier::Substring)
662+
);
663+
}
664+
665+
#[test]
666+
fn ascii_eq_ignore_case_matches() {
667+
assert!(ascii_eq_ignore_case("Foo", "foo"));
668+
assert!(!ascii_eq_ignore_case("FooBar", "foo"));
669+
}
670+
671+
#[test]
672+
fn ascii_starts_with_ignore_case_matches() {
673+
assert!(ascii_starts_with_ignore_case("FooBar", "foo"));
674+
assert!(!ascii_starts_with_ignore_case("Foo", "foobar"));
675+
}
676+
677+
#[test]
678+
fn ascii_contains_ignore_case_matches() {
679+
assert!(ascii_contains_ignore_case("MyFooBar", "foo"));
680+
assert!(!ascii_contains_ignore_case("MyBar", "foo"));
681+
assert!(ascii_contains_ignore_case("Anything", ""));
682+
}
609683
}

0 commit comments

Comments
 (0)