From 0e0e8840661f796331de89bedd6ecaff5b027fcb Mon Sep 17 00:00:00 2001 From: mariari Date: Sun, 28 Jun 2026 17:43:55 +0800 Subject: [PATCH 1/6] Filter the Functions list with GT's filter-v2 model bar The Functions tab listed every def/type/macro with no way to narrow it. GT's coder filter bars ("Category [All methods v]", "Methods up to v") use a model-backed filter system: a filter answers filterDescriptor2For: with a GtFilter*Model, and GtFilterItemsModel asFiltersElement renders native dropdown widgets (proper box, alt-clickable). We reuse that rather than hand-rolling tags. ElixirFunctionsFilter is a GtSearchFilter base matching one function entry (matches:); =/hash are value-aware so GtFilteredCodersModel>>additionalFilters:, which early-returns on an equal collection, re-applies on every change. ElixirKindFilter answers a GtFilterShortListModel (All/Functions/Types, shown by default); ElixirNameSubstringFilter a GtFilterTextModel (added via +). ElixirFunctionFiltersBuilder enumerates ElixirFunctionsFilter subclasses into the + menu (GT's GtFilterMethodsCoderAvailableFiltersBuilder scans GtSearchMethodsFilter, which we aren't). buildFunctionsFilterBar: assembles GtFilterItemsModel + that builder + GT's GtFilterMethodCodersAdditionalFiltersUpdater (which only needs additionalFilters: and model asSearchFilter), the updater kept alive in the element userData. newItemsStream narrows functionEntries by additionalFilters. Subclassing ElixirFunctionsFilter with a filterDescriptor2For: model is the whole extension story: a new filter auto-registers in the bar. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ElixirFunctionFiltersBuilder.class.st | 31 ++++++++++ .../ElixirFunctionStreamingModel.class.st | 11 ++-- src/Gt4beam/ElixirFunctionsFilter.class.st | 43 ++++++++++++++ src/Gt4beam/ElixirKindFilter.class.st | 56 +++++++++++++++++++ src/Gt4beam/ElixirModuleCoder.class.st | 33 ++++++++++- .../ElixirNameSubstringFilter.class.st | 51 +++++++++++++++++ 6 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 src/Gt4beam/ElixirFunctionFiltersBuilder.class.st create mode 100644 src/Gt4beam/ElixirFunctionsFilter.class.st create mode 100644 src/Gt4beam/ElixirKindFilter.class.st create mode 100644 src/Gt4beam/ElixirNameSubstringFilter.class.st diff --git a/src/Gt4beam/ElixirFunctionFiltersBuilder.class.st b/src/Gt4beam/ElixirFunctionFiltersBuilder.class.st new file mode 100644 index 00000000..9b2329be --- /dev/null +++ b/src/Gt4beam/ElixirFunctionFiltersBuilder.class.st @@ -0,0 +1,31 @@ +Class { + #name : 'ElixirFunctionFiltersBuilder', + #superclass : 'Object', + #instVars : [ + 'coders' + ], + #category : 'Gt4beam-Filters', + #package : 'Gt4beam', + #tag : 'Filters' +} + +{ #category : 'accessing' } +ElixirFunctionFiltersBuilder >> availableFilters [ + "All Elixir function filters' descriptor-2 models, sorted by order (the + menu source)." + | available | + coders ifNil: [ ^ #() ]. + available := SortedCollection sortBlock: [ :a :b | a order < b order ]. + ElixirFunctionsFilter withAllSubclassesDo: [ :each | + each filterDescriptors2For: coders into: available ]. + ^ available +] + +{ #category : 'accessing' } +ElixirFunctionFiltersBuilder >> coders [ + ^ coders +] + +{ #category : 'accessing' } +ElixirFunctionFiltersBuilder >> coders: aCoders [ + coders := aCoders +] diff --git a/src/Gt4beam/ElixirFunctionStreamingModel.class.st b/src/Gt4beam/ElixirFunctionStreamingModel.class.st index db5ff60f..710e9852 100644 --- a/src/Gt4beam/ElixirFunctionStreamingModel.class.st +++ b/src/Gt4beam/ElixirFunctionStreamingModel.class.st @@ -60,13 +60,10 @@ ElixirFunctionStreamingModel >> newCoderFor: anEntry [ { #category : 'streaming' } ElixirFunctionStreamingModel >> newItemsStream [ - "I return function entries as an async stream in the order - GtBridge.Analysis.all_functions/1 emits them. The BEAM-side - already groups default-arg arity siblings (start: 0) above - their AST counterpart and parks orphan macro-generated entries - at the end, so re-sorting by start would just lump every - start: 0 entry at the top." - ^ moduleCoder functionEntries asAsyncStream + "additionalFilters (not compositeFilter): the null main filter isn't a + predicate, and entries keep all_functions/1 order via select:." + ^ (moduleCoder functionEntries select: [ :e | + self additionalFilters allSatisfy: [ :f | f matches: e ] ]) asAsyncStream ] { #category : 'announcements' } diff --git a/src/Gt4beam/ElixirFunctionsFilter.class.st b/src/Gt4beam/ElixirFunctionsFilter.class.st new file mode 100644 index 00000000..3582db4b --- /dev/null +++ b/src/Gt4beam/ElixirFunctionsFilter.class.st @@ -0,0 +1,43 @@ +Class { + #name : 'ElixirFunctionsFilter', + #superclass : 'GtSearchFilter', + #category : 'Gt4beam-Filters', + #package : 'Gt4beam', + #tag : 'Filters' +} + +{ #category : 'descriptors' } +ElixirFunctionsFilter class >> filterDescriptors2For: aCoder into: aCollection [ + (self filterDescriptor2For: aCoder) + ifNotNil: [ :aDescriptor | aCollection add: aDescriptor ] +] + +{ #category : 'comparing' } +ElixirFunctionsFilter >> = anObject [ + "Value-aware (stock GtSearchFilter compares class only) so a changed value re-applies." + ^ self class = anObject class and: [ self filterValueString = anObject filterValueString ] +] + +{ #category : 'matching' } +ElixirFunctionsFilter >> entryKind: anEntry [ + "Normalised kind, '#' stripped: the bridge sends it as 'def'/'type' + (ByteString) or #def/#type (BeamAtomObject) depending on the path." + ^ (anEntry attributeAt: #kind) + ifNil: [ '' ] + ifNotNil: [ :k | k asString copyWithout: $# ] +] + +{ #category : 'comparing' } +ElixirFunctionsFilter >> hash [ + ^ self class hash bitXor: self filterValueString hash +] + +{ #category : 'matching' } +ElixirFunctionsFilter >> isType: anEntry [ + ^ #('type' 'opaque' 'typep') includes: (self entryKind: anEntry) +] + +{ #category : 'matching' } +ElixirFunctionsFilter >> matches: anEntry [ + ^ true +] diff --git a/src/Gt4beam/ElixirKindFilter.class.st b/src/Gt4beam/ElixirKindFilter.class.st new file mode 100644 index 00000000..3eb8ca2c --- /dev/null +++ b/src/Gt4beam/ElixirKindFilter.class.st @@ -0,0 +1,56 @@ +Class { + #name : 'ElixirKindFilter', + #superclass : 'ElixirFunctionsFilter', + #instVars : [ + 'kind' + ], + #category : 'Gt4beam-Filters', + #package : 'Gt4beam', + #tag : 'Filters' +} + +{ #category : 'instance creation' } +ElixirKindFilter class >> forKind: aString [ + ^ self new kind: aString +] + +{ #category : 'descriptors' } +ElixirKindFilter class >> globalFilterDescriptor2 [ + ^ GtFilterShortListModel new + creator: [ :item | self forKind: item itemValue ]; + items: [ #('All' 'Functions' 'Types') ]; + selectedItem: 'All'; + displayAllItems; + name: 'Kind'; + order: 20; + beDefault; + yourself +] + +{ #category : 'descriptors' } +ElixirKindFilter >> filterDescriptor2For: aCoder [ + ^ (super filterDescriptor2For: aCoder) + ifNotNil: [ :aFilterModel | aFilterModel selectedItem: kind; yourself ] +] + +{ #category : 'accessing' } +ElixirKindFilter >> filterValueString [ + ^ kind +] + +{ #category : 'accessing' } +ElixirKindFilter >> kind [ + ^ kind +] + +{ #category : 'accessing' } +ElixirKindFilter >> kind: aString [ + kind := aString +] + +{ #category : 'matching' } +ElixirKindFilter >> matches: anEntry [ + kind = 'Types' ifTrue: [ ^ self isType: anEntry ]. + kind = 'Functions' ifTrue: [ ^ (self isType: anEntry) not ]. + ^ true +] diff --git a/src/Gt4beam/ElixirModuleCoder.class.st b/src/Gt4beam/ElixirModuleCoder.class.st index 1e24eb73..b5dc6bd8 100644 --- a/src/Gt4beam/ElixirModuleCoder.class.st +++ b/src/Gt4beam/ElixirModuleCoder.class.st @@ -167,6 +167,28 @@ ElixirModuleCoder >> buildExampleMapElement [ ^ mondrian root ] +{ #category : 'private' } +ElixirModuleCoder >> buildFunctionsFilterBar: aStreamingModel [ + "GT filter-v2 bar: GtFilterItemsModel renders our filter models as native dropdowns + (Kind default-shown, others via +); the updater syncs picks into additionalFilters." + | aModel builder active defaults bar updater | + aModel := GtFilterItemsModel new. + builder := ElixirFunctionFiltersBuilder new coders: aStreamingModel. + aModel availableFiltersBuilder: builder. + active := (aStreamingModel additionalFilters + collect: [ :f | f filterDescriptor2For: aStreamingModel ]) reject: [ :x | x isNil ]. + defaults := builder availableFilters + select: [ :m | m isDefaultFilterModel and: [ (active includes: m) not ] ]. + aModel items: (Array streamContents: [ :s | s nextPutAll: active. s nextPutAll: defaults ]). + bar := aModel asFiltersElement. + updater := GtFilterMethodCodersAdditionalFiltersUpdater new + coders: aStreamingModel; + filtersModel: aModel. + bar userData at: #elixirFilterUpdater put: updater. + bar constraintsDo: [ :c | c horizontal matchParent. c vertical fitContent ]. + ^ bar +] + { #category : 'private' } ElixirModuleCoder >> buildFunctionsViewElement [ "I build the Functions tab. If focusLine is set, I set a scroll @@ -174,7 +196,7 @@ ElixirModuleCoder >> buildFunctionsViewElement [ scroll via list scrollToItemSuchThat:offset: when the matching item loads (same pattern as GtPharoStreamingMethodsCoderElement). Each function coder starts folded so the user scans the list." - | container addRow streaming vm streamingElement focusStart | + | container addRow streaming vm streamingElement focusStart filterBar | container := BlElement new layout: BlLinearLayout vertical; constraintsDo: [ :c | @@ -212,7 +234,14 @@ ElixirModuleCoder >> buildFunctionsViewElement [ container addChild: addRow; addChild: streamingElement matchParent. - ^ container + filterBar := self buildFunctionsFilterBar: streaming. + "Wrap rather than sibling addRow: the draft pane inserts by index into container." + ^ BlElement new + layout: BlLinearLayout vertical; + constraintsDo: [ :c | c horizontal matchParent. c vertical matchParent ]; + addChild: filterBar; + addChild: container; + yourself ] { #category : 'private' } diff --git a/src/Gt4beam/ElixirNameSubstringFilter.class.st b/src/Gt4beam/ElixirNameSubstringFilter.class.st new file mode 100644 index 00000000..0c80bd34 --- /dev/null +++ b/src/Gt4beam/ElixirNameSubstringFilter.class.st @@ -0,0 +1,51 @@ +Class { + #name : 'ElixirNameSubstringFilter', + #superclass : 'ElixirFunctionsFilter', + #instVars : [ + 'substring' + ], + #category : 'Gt4beam-Filters', + #package : 'Gt4beam', + #tag : 'Filters' +} + +{ #category : 'instance creation' } +ElixirNameSubstringFilter class >> forSubstring: aString [ + ^ self new substring: aString +] + +{ #category : 'descriptors' } +ElixirNameSubstringFilter class >> globalFilterDescriptor2 [ + ^ GtFilterTextModel new + creator: [ :value | self forSubstring: value ]; + named: 'Name'; + order: 15; + yourself +] + +{ #category : 'descriptors' } +ElixirNameSubstringFilter >> filterDescriptor2For: aCoder [ + ^ (super filterDescriptor2For: aCoder) + ifNotNil: [ :aFilterModel | aFilterModel text: substring; yourself ] +] + +{ #category : 'accessing' } +ElixirNameSubstringFilter >> filterValueString [ + ^ substring +] + +{ #category : 'matching' } +ElixirNameSubstringFilter >> matches: anEntry [ + ^ (anEntry attributeAt: #name) asString asLowercase + includesSubstring: substring asLowercase +] + +{ #category : 'accessing' } +ElixirNameSubstringFilter >> substring [ + ^ substring +] + +{ #category : 'accessing' } +ElixirNameSubstringFilter >> substring: aString [ + substring := aString +] From cfab6a634018cb45ae13e5e4b1fd1aa43caf5fd7 Mon Sep 17 00:00:00 2001 From: mariari Date: Sat, 4 Jul 2026 10:01:24 +0800 Subject: [PATCH 2/6] Create a filter for substrings of Elixir Terms The filter-v2 bar only narrowed by kind and name; there was no way to find functions by what their source contains. ElixirBodySubstringFilter matches an entry when its source includes the substring, with the same value-aware =/hash so re-picking a value re-applies the filter. --- .../ElixirBodySubstringFilter.class.st | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/Gt4beam/ElixirBodySubstringFilter.class.st diff --git a/src/Gt4beam/ElixirBodySubstringFilter.class.st b/src/Gt4beam/ElixirBodySubstringFilter.class.st new file mode 100644 index 00000000..db23c315 --- /dev/null +++ b/src/Gt4beam/ElixirBodySubstringFilter.class.st @@ -0,0 +1,53 @@ +Class { + #name : 'ElixirBodySubstringFilter', + #superclass : 'ElixirFunctionsFilter', + #instVars : [ + 'substring' + ], + #category : 'Gt4beam-Filters', + #package : 'Gt4beam', + #tag : 'Filters' +} + +{ #category : 'descriptors' } +ElixirBodySubstringFilter class >> globalFilterDescriptor2 [ + ^ GtFilterTextModel new + creator: [ :value | self substring: value ]; + named: 'Substring'; + order: 10; + yourself +] + +{ #category : 'descriptors' } +ElixirBodySubstringFilter class >> substring: value [ + ^ self new substring: value +] + +{ #category : 'descriptors' } +ElixirBodySubstringFilter >> filterDescriptor2For: aMethodsCoder [ + ^ (super filterDescriptor2For: aMethodsCoder) + ifNotNil: [ :aFilterModel | + aFilterModel + text: substring; + yourself ] +] + +{ #category : 'accessing' } +ElixirBodySubstringFilter >> filterValueString [ + ^ self substring +] + +{ #category : 'testing' } +ElixirBodySubstringFilter >> matches: anEntry [ + ^ (anEntry attributeAt: #source) includesSubstring: self substring +] + +{ #category : 'accessing' } +ElixirBodySubstringFilter >> substring [ + ^ substring +] + +{ #category : 'accessing' } +ElixirBodySubstringFilter >> substring: anObject [ + substring := anObject +] From 60a3a627c486ef341b90308a9112e52210a11246 Mon Sep 17 00:00:00 2001 From: mariari Date: Sat, 4 Jul 2026 10:01:24 +0800 Subject: [PATCH 3/6] Add a highlighting color to substring search A substring filter narrowed the list but gave no cue WHERE the match sat inside each function. The streaming model now exposes the active filters to an ElixirSubstringHighlightStyler (wired via the gtAstCoderAddOns hook) that paints each filter's highlightRangesIn: ranges over the function source, so matches are visible in place. --- .../ElixirBodySubstringFilter.class.st | 5 ++ src/Gt4beam/ElixirFunctionCoder.class.st | 9 ++++ .../ElixirFunctionStreamingModel.class.st | 15 ++++++ src/Gt4beam/ElixirFunctionsFilter.class.st | 18 +++++++ src/Gt4beam/ElixirModuleCoder.class.st | 17 ++++++- .../ElixirNameSubstringFilter.class.st | 5 ++ .../ElixirSubstringHighlightStyler.class.st | 49 +++++++++++++++++++ 7 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/Gt4beam/ElixirSubstringHighlightStyler.class.st diff --git a/src/Gt4beam/ElixirBodySubstringFilter.class.st b/src/Gt4beam/ElixirBodySubstringFilter.class.st index db23c315..14e0d8c4 100644 --- a/src/Gt4beam/ElixirBodySubstringFilter.class.st +++ b/src/Gt4beam/ElixirBodySubstringFilter.class.st @@ -37,6 +37,11 @@ ElixirBodySubstringFilter >> filterValueString [ ^ self substring ] +{ #category : 'highlighting' } +ElixirBodySubstringFilter >> highlightRangesIn: aString [ + ^ self rangesOf: self substring in: aString +] + { #category : 'testing' } ElixirBodySubstringFilter >> matches: anEntry [ ^ (anEntry attributeAt: #source) includesSubstring: self substring diff --git a/src/Gt4beam/ElixirFunctionCoder.class.st b/src/Gt4beam/ElixirFunctionCoder.class.st index 2cc3c702..45038057 100644 --- a/src/Gt4beam/ElixirFunctionCoder.class.st +++ b/src/Gt4beam/ElixirFunctionCoder.class.st @@ -208,6 +208,15 @@ ElixirFunctionCoder >> hasSource [ ^ startLine isNotNil and: [ startLine > 0 ] ] +{ #category : 'add-ons' } +ElixirFunctionCoder >> highlightFiltersFor: anAst into: coderAddOns [ + "Underline the active substring filters matches in the source. A + pragma (not initializeAddOns:) so it is re-collected + re-run on every addOns rebuild." + + + coderAddOns addStyler: ElixirSubstringHighlightStyler new +] + { #category : 'as yet unclassified' } ElixirFunctionCoder >> initializeAddOns: addOns [ super initializeAddOns: addOns. diff --git a/src/Gt4beam/ElixirFunctionStreamingModel.class.st b/src/Gt4beam/ElixirFunctionStreamingModel.class.st index 710e9852..54246809 100644 --- a/src/Gt4beam/ElixirFunctionStreamingModel.class.st +++ b/src/Gt4beam/ElixirFunctionStreamingModel.class.st @@ -75,6 +75,12 @@ ElixirFunctionStreamingModel >> onModuleSourceCodeChanged: anAnnouncement [ self refreshStream ] +{ #category : 'streaming' } +ElixirFunctionStreamingModel >> refreshItemsStreamDueTo: aReason [ + super refreshItemsStreamDueTo: aReason. + self restyleHighlightCoders +] + { #category : 'as yet unclassified' } ElixirFunctionStreamingModel >> refreshStream [ "Drops cached entries whose key isn't in the fresh set — @@ -93,3 +99,12 @@ ElixirFunctionStreamingModel >> refreshStream [ (freshKeys includes: k) ifFalse: [ coders removeKey: k ] ] ]. self refreshItemsStreamDueTo: 'functions reordered' ] + +{ #category : 'streaming' } +ElixirFunctionStreamingModel >> restyleHighlightCoders [ + "Rebuild each cached coder's addOns so the highlight styler re-reads + the active substrings and re-underlines (or clears) after a filter change." + + (mutex critical: [ coders values asArray ]) + do: [ :coder | coder requestUpdateAddOns ] +] diff --git a/src/Gt4beam/ElixirFunctionsFilter.class.st b/src/Gt4beam/ElixirFunctionsFilter.class.st index 3582db4b..567e8fce 100644 --- a/src/Gt4beam/ElixirFunctionsFilter.class.st +++ b/src/Gt4beam/ElixirFunctionsFilter.class.st @@ -32,6 +32,12 @@ ElixirFunctionsFilter >> hash [ ^ self class hash bitXor: self filterValueString hash ] +{ #category : 'highlighting' } +ElixirFunctionsFilter >> highlightRangesIn: aString [ + "Source ranges I want highlighted. None by default; substring filters override." + ^ #() +] + { #category : 'matching' } ElixirFunctionsFilter >> isType: anEntry [ ^ #('type' 'opaque' 'typep') includes: (self entryKind: anEntry) @@ -41,3 +47,15 @@ ElixirFunctionsFilter >> isType: anEntry [ ElixirFunctionsFilter >> matches: anEntry [ ^ true ] + +{ #category : 'highlighting' } +ElixirFunctionsFilter >> rangesOf: aSubstring in: aString [ + | ranges idx | + (aSubstring isNil or: [ aSubstring isEmpty ]) ifTrue: [ ^ #() ]. + ranges := OrderedCollection new. + idx := aString indexOfSubCollection: aSubstring startingAt: 1. + [ idx > 0 ] whileTrue: [ + ranges add: (idx to: idx + aSubstring size - 1). + idx := aString indexOfSubCollection: aSubstring startingAt: idx + 1 ]. + ^ ranges +] diff --git a/src/Gt4beam/ElixirModuleCoder.class.st b/src/Gt4beam/ElixirModuleCoder.class.st index b5dc6bd8..48bfdd81 100644 --- a/src/Gt4beam/ElixirModuleCoder.class.st +++ b/src/Gt4beam/ElixirModuleCoder.class.st @@ -12,7 +12,8 @@ Class { 'sessionId', 'selfObject', 'saving', - 'sourceCache' + 'sourceCache', + 'functionStreamingModel' ], #category : 'Gt4beam-Coder', #package : 'Gt4beam', @@ -170,8 +171,10 @@ ElixirModuleCoder >> buildExampleMapElement [ { #category : 'private' } ElixirModuleCoder >> buildFunctionsFilterBar: aStreamingModel [ "GT filter-v2 bar: GtFilterItemsModel renders our filter models as native dropdowns - (Kind default-shown, others via +); the updater syncs picks into additionalFilters." + (Kind default-shown, others via +); the updater syncs picks into additionalFilters. + Also kept as functionStreamingModel so the source highlighter can read the active substrings." | aModel builder active defaults bar updater | + self functionStreamingModel: aStreamingModel. aModel := GtFilterItemsModel new. builder := ElixirFunctionFiltersBuilder new coders: aStreamingModel. aModel availableFiltersBuilder: builder. @@ -387,6 +390,16 @@ ElixirModuleCoder >> functionEntries [ ifFalse: [ result ] ] +{ #category : 'accessing' } +ElixirModuleCoder >> functionStreamingModel [ + ^ functionStreamingModel +] + +{ #category : 'accessing' } +ElixirModuleCoder >> functionStreamingModel: aModel [ + functionStreamingModel := aModel +] + { #category : 'as yet unclassified' } ElixirModuleCoder >> gtDependenciesViewFor: aView context: aContext [ diff --git a/src/Gt4beam/ElixirNameSubstringFilter.class.st b/src/Gt4beam/ElixirNameSubstringFilter.class.st index 0c80bd34..c348ac2a 100644 --- a/src/Gt4beam/ElixirNameSubstringFilter.class.st +++ b/src/Gt4beam/ElixirNameSubstringFilter.class.st @@ -34,6 +34,11 @@ ElixirNameSubstringFilter >> filterValueString [ ^ substring ] +{ #category : 'highlighting' } +ElixirNameSubstringFilter >> highlightRangesIn: aString [ + ^ self rangesOf: self substring in: aString +] + { #category : 'matching' } ElixirNameSubstringFilter >> matches: anEntry [ ^ (anEntry attributeAt: #name) asString asLowercase diff --git a/src/Gt4beam/ElixirSubstringHighlightStyler.class.st b/src/Gt4beam/ElixirSubstringHighlightStyler.class.st new file mode 100644 index 00000000..1f8d8199 --- /dev/null +++ b/src/Gt4beam/ElixirSubstringHighlightStyler.class.st @@ -0,0 +1,49 @@ +Class { + #name : 'ElixirSubstringHighlightStyler', + #superclass : 'GtCoderAstStyler', + #category : 'Gt4beam-Styler', + #package : 'Gt4beam', + #tag : 'Styler' +} + +{ #category : 'accessing' } +ElixirSubstringHighlightStyler class >> highlightAttribute [ + ^ BlTextDecorationAttribute new + underline; + color: BrGlamorousColors textHighlightColor; + yourself +] + +{ #category : 'accessing' } +ElixirSubstringHighlightStyler >> activeFilters [ + "The filters currently applied to the function list, empty when unattached." + + | coder streaming | + coder := self attachedCoder. + coder isNil ifTrue: [ ^ #() ]. + streaming := coder moduleCoder functionStreamingModel. + ^ streaming isNil ifTrue: [ #() ] ifFalse: [ streaming additionalFilters ] +] + +{ #category : 'accessing' } +ElixirSubstringHighlightStyler >> attachedCoder [ + ^ self coderViewModel ifNotNil: [ :vm | vm coderModel ] +] + +{ #category : 'styling' } +ElixirSubstringHighlightStyler >> style: aText ast: theAst [ + "Underline the source ranges each active filter asks to highlight." + + | attr source | + text := aText. + source := aText asString. + attr := self class highlightAttribute. + self activeFilters + do: [ :filter | + (filter highlightRangesIn: source) + do: [ :range | + self + attribute: attr + from: range first + to: range last ] ] +] From 73150c7121b4f2347865fc7b89678755b6f10952 Mon Sep 17 00:00:00 2001 From: mariari Date: Wed, 1 Jul 2026 23:24:32 +0800 Subject: [PATCH 4/6] Treat functionStreamingModel as a moduleCoder contract in activeFilters activeFilters reached `coder moduleCoder functionStreamingModel` with no guard, so a coder acting as moduleCoder that doesn't implement functionStreamingModel -- e.g. AL's ALClassCoder, which mirrors ElixirModuleCoder's role but subclasses Object -- raised a DNU while styling. Make the chain nil-safe and rely on functionStreamingModel as a module-coder contract with a nil default (filters empty when the model is absent), rather than duck-typing with respondsTo:. Co-Authored-By: Claude Opus 4.8 --- src/Gt4beam/ElixirSubstringHighlightStyler.class.st | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Gt4beam/ElixirSubstringHighlightStyler.class.st b/src/Gt4beam/ElixirSubstringHighlightStyler.class.st index 1f8d8199..f306890f 100644 --- a/src/Gt4beam/ElixirSubstringHighlightStyler.class.st +++ b/src/Gt4beam/ElixirSubstringHighlightStyler.class.st @@ -16,13 +16,11 @@ ElixirSubstringHighlightStyler class >> highlightAttribute [ { #category : 'accessing' } ElixirSubstringHighlightStyler >> activeFilters [ - "The filters currently applied to the function list, empty when unattached." + "Empty when the coder's functionStreamingModel is nil (no filter bar, e.g. ALClassCoder)." - | coder streaming | - coder := self attachedCoder. - coder isNil ifTrue: [ ^ #() ]. - streaming := coder moduleCoder functionStreamingModel. - ^ streaming isNil ifTrue: [ #() ] ifFalse: [ streaming additionalFilters ] + | streaming | + streaming := self attachedCoder ifNotNil: [ :c | c moduleCoder functionStreamingModel ]. + ^ streaming ifNil: [ #( ) ] ifNotNil: [ :s | s additionalFilters ] ] { #category : 'accessing' } From e6923c85b353137255c9a2d44a8c471ce930ed39 Mon Sep 17 00:00:00 2001 From: mariari Date: Sat, 4 Jul 2026 10:01:59 +0800 Subject: [PATCH 5/6] Highlight case-insensitively, agreeing with the match The Name filter matched case-insensitively but highlightRangesIn: was case-sensitive: filtering by GET matched get_user yet highlighted nothing, while a lowercase query lit up unrelated exact-case hits. The Body filter had the reverse split (case-sensitive match). rangesOf:in: scans lowered copies now and both substring filters match case-insensitively, so what matches is what lights up. The Body filter's class-side constructor also becomes forSubstring: ('instance creation') matching its Name sibling; substring: read as a setter. --- src/Gt4beam/ElixirBodySubstringFilter.class.st | 15 ++++++++------- src/Gt4beam/ElixirFunctionsFilter.class.st | 12 ++++++++---- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/Gt4beam/ElixirBodySubstringFilter.class.st b/src/Gt4beam/ElixirBodySubstringFilter.class.st index 14e0d8c4..e45abceb 100644 --- a/src/Gt4beam/ElixirBodySubstringFilter.class.st +++ b/src/Gt4beam/ElixirBodySubstringFilter.class.st @@ -9,20 +9,20 @@ Class { #tag : 'Filters' } +{ #category : 'instance creation' } +ElixirBodySubstringFilter class >> forSubstring: value [ + ^ self new substring: value +] + { #category : 'descriptors' } ElixirBodySubstringFilter class >> globalFilterDescriptor2 [ ^ GtFilterTextModel new - creator: [ :value | self substring: value ]; + creator: [ :value | self forSubstring: value ]; named: 'Substring'; order: 10; yourself ] -{ #category : 'descriptors' } -ElixirBodySubstringFilter class >> substring: value [ - ^ self new substring: value -] - { #category : 'descriptors' } ElixirBodySubstringFilter >> filterDescriptor2For: aMethodsCoder [ ^ (super filterDescriptor2For: aMethodsCoder) @@ -44,7 +44,8 @@ ElixirBodySubstringFilter >> highlightRangesIn: aString [ { #category : 'testing' } ElixirBodySubstringFilter >> matches: anEntry [ - ^ (anEntry attributeAt: #source) includesSubstring: self substring + ^ (anEntry attributeAt: #source) asString asLowercase + includesSubstring: self substring asLowercase ] { #category : 'accessing' } diff --git a/src/Gt4beam/ElixirFunctionsFilter.class.st b/src/Gt4beam/ElixirFunctionsFilter.class.st index 567e8fce..ea40688d 100644 --- a/src/Gt4beam/ElixirFunctionsFilter.class.st +++ b/src/Gt4beam/ElixirFunctionsFilter.class.st @@ -50,12 +50,16 @@ ElixirFunctionsFilter >> matches: anEntry [ { #category : 'highlighting' } ElixirFunctionsFilter >> rangesOf: aSubstring in: aString [ - | ranges idx | + "Case-insensitive, agreeing with the filters' matches:. Scans + lowered copies; the ranges index the original string." + | ranges idx lowered target | (aSubstring isNil or: [ aSubstring isEmpty ]) ifTrue: [ ^ #() ]. + lowered := aSubstring asLowercase. + target := aString asLowercase. ranges := OrderedCollection new. - idx := aString indexOfSubCollection: aSubstring startingAt: 1. + idx := target indexOfSubCollection: lowered startingAt: 1. [ idx > 0 ] whileTrue: [ - ranges add: (idx to: idx + aSubstring size - 1). - idx := aString indexOfSubCollection: aSubstring startingAt: idx + 1 ]. + ranges add: (idx to: idx + lowered size - 1). + idx := target indexOfSubCollection: lowered startingAt: idx + 1 ]. ^ ranges ] From 1e5e712a4e6a3cde23968fb34d107662fbe0fff8 Mon Sep 17 00:00:00 2001 From: mariari Date: Sat, 4 Jul 2026 10:02:49 +0800 Subject: [PATCH 6/6] Add examples for the filters' pure logic matches:, entryKind: normalisation, and the match/highlight agreement were only exercised through the live UI; these run headless against constructed BeamFunctionEntry instances and pin the case-insensitive contract the previous commit established. --- .../ElixirFunctionFilterExamples.class.st | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/Gt4beam/ElixirFunctionFilterExamples.class.st diff --git a/src/Gt4beam/ElixirFunctionFilterExamples.class.st b/src/Gt4beam/ElixirFunctionFilterExamples.class.st new file mode 100644 index 00000000..790abcbc --- /dev/null +++ b/src/Gt4beam/ElixirFunctionFilterExamples.class.st @@ -0,0 +1,68 @@ +" +I demonstrate the Functions-list filters' pure logic: kind +normalisation, substring matching, and the match/highlight agreement. +" +Class { + #name : 'ElixirFunctionFilterExamples', + #superclass : 'Object', + #category : 'Gt4beam-Examples', + #package : 'Gt4beam', + #tag : 'Examples' +} + +{ #category : 'examples' } +ElixirFunctionFilterExamples >> bodyFilterMatchesCaseInsensitively [ + "The Body filter finds a substring anywhere in the source, + regardless of case, like its Name sibling." + + + | filter | + filter := ElixirBodySubstringFilter forSubstring: 'MNESIA'. + self assert: (filter matches: (self entryNamed: 'go' kind: 'def' + source: 'def go, do: :mnesia.transaction(fn -> :ok end)')). + self assert: (filter matches: (self entryNamed: 'go' kind: 'def' + source: 'def go, do: :ets.lookup(:t, :k)')) not. + ^ filter +] + +{ #category : 'support' } +ElixirFunctionFilterExamples >> entryNamed: aName kind: aKind source: aSource [ + ^ BeamFunctionEntry fields: (Dictionary new + at: 'name' put: aName; + at: 'kind' put: aKind; + at: 'source' put: aSource; + yourself) +] + +{ #category : 'examples' } +ElixirFunctionFilterExamples >> kindFilterNormalisesKindShapes [ + "The bridge sends kind as 'def'/'type' or #def/#type depending on + the eval path; entryKind: strips the # so one filter covers both." + + + | filter | + filter := ElixirKindFilter forKind: 'Types'. + self assert: (filter matches: (self entryNamed: 't' kind: 'type' source: '@type t :: atom')). + self assert: (filter matches: (self entryNamed: 't' kind: '#type' source: '@type t :: atom')). + self assert: (filter matches: (self entryNamed: 'go' kind: 'def' source: 'def go, do: :ok')) not. + ^ filter +] + +{ #category : 'examples' } +ElixirFunctionFilterExamples >> nameFilterHighlightAgreesWithMatch [ + "What matches is what lights up: an uppercase query matches a + lowercase name AND highlights it; the ranges index the original + string." + + + | filter source ranges | + filter := ElixirNameSubstringFilter forSubstring: 'GET'. + self assert: (filter matches: (self entryNamed: 'get_user' kind: 'def' + source: 'def get_user, do: :ok')). + source := 'def get_user, do: forget()'. + ranges := filter highlightRangesIn: source. + self assert: ranges size equals: 2. + ranges do: [ :r | + self assert: (source copyFrom: r first to: r last) asLowercase equals: 'get' ]. + ^ filter +]