Skip to content

Commit 403b756

Browse files
committed
Simplify CompletionContext — remove unused substitution metadata
Remove SubstitutionDepth, SubstitutionKind, SubstitutionScope, SubstitutionScopes, and related helpers. The inner-first approach means the standard fields (Words, CurrentWord, etc.) already describe the inner command when inside a substitution — callers don't need depth/kind metadata. Replace with a single innermostUnclosedCommandScope helper that finds the innermost unclosed $( opener and extracts inner tokens. Assisted-by: Crush:glm-5.2
1 parent cfb6c31 commit 403b756

4 files changed

Lines changed: 38 additions & 279 deletions

File tree

completion.go

Lines changed: 10 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ package shlex
55
// tokens.CurrentPipeline().FilterRedirects().Words().CurrentToken() chains.
66
type CompletionContext struct {
77
// Words are the dequoted word values in the current pipeline
8-
// (redirects filtered). Equivalent to:
9-
// tokens.CurrentPipeline().FilterRedirects().Words().Strings()
8+
// (redirects filtered). When the cursor is inside a substitution
9+
// scope (e.g. $(...), these are the inner command's words.
1010
Words []string
1111

1212
// CurrentWord is the word at the cursor position (dequoted Value).
@@ -17,13 +17,11 @@ type CompletionContext struct {
1717
RawCurrentWord string
1818

1919
// Prefix is the wordbreak prefix up to the cursor.
20-
// Equivalent to tokens.CurrentPipeline().WordbreakPrefix().
2120
Prefix string
2221

2322
// QuotingState is the lexer state of the current word.
2423
// IN_WORD_STATE, QUOTING_STATE, QUOTING_ESCAPING_STATE, QUOTING_TRIPLE_STATE,
2524
// QUOTING_TRIPLE_ESCAPING_STATE, or ESCAPING_STATE.
26-
// Replaces the regex-based quoting detection in carapace's zsh action.
2725
QuotingState LexerState
2826

2927
// IsRedirect is true when the cursor is completing a redirect target
@@ -39,57 +37,31 @@ type CompletionContext struct {
3937
// redirect filtering and word merging). Use this as an escape hatch
4038
// for edge cases not covered by the fields above.
4139
Pipeline TokenSlice
42-
43-
// SubstitutionDepth is the number of unclosed substitution scopes at
44-
// the cursor position. 0 = cursor at top level. When > 0, all other
45-
// fields (Words, CurrentWord, etc.) describe the innermost
46-
// substitution's command, not the outer command.
47-
SubstitutionDepth int
48-
49-
// SubstitutionKind indicates what type of substitution the cursor is
50-
// inside (command, arithmetic, backtick). Only meaningful when
51-
// SubstitutionDepth > 0.
52-
SubstitutionKind SubstitutionKind
5340
}
5441

5542
// SplitForCompletion parses s and returns a CompletionContext describing
5643
// the completion state at the end of the string, using the given format.
5744
//
5845
// When the cursor is inside an unclosed substitution scope (e.g. inside
59-
// $(...), the returned context describes the innermost substitution's
60-
// command, not the outer command. SubstitutionDepth indicates how many
61-
// nesting levels deep the cursor is.
46+
// $(...), the context describes the innermost substitution's command,
47+
// not the outer command.
6248
func SplitForCompletion(s string, format Format) *CompletionContext {
6349
tokens, err := SplitWith(s, format)
6450
if err != nil || len(tokens) == 0 {
6551
return &CompletionContext{QuotingState: START_STATE}
6652
}
6753

68-
// Check for unclosed substitution scopes at cursor
69-
scopes := tokens.SubstitutionScopes()
70-
innermost := innermostUnclosedScope(scopes)
71-
72-
if innermost != nil && innermost.Kind != SUBSTITUTION_ARITHMETIC && innermost.OpenIndex >= 0 {
73-
// Cursor inside a command substitution — build context from inner tokens
74-
innerTokens := tokens[innermost.OpenIndex+1:]
75-
ctx := buildCompletionContext(innerTokens)
76-
ctx.SubstitutionDepth = countUnclosedScopes(scopes)
77-
ctx.SubstitutionKind = innermost.Kind
78-
return ctx
54+
// If cursor is inside an unclosed command substitution, build the
55+
// context from the inner tokens.
56+
if scope := innermostUnclosedCommandScope(tokens); scope >= 0 {
57+
return buildCompletionContext(tokens[scope+1:])
7958
}
8059

81-
// Cursor at top level (or inside arithmetic/backtick — no inner command)
82-
ctx := buildCompletionContext(tokens)
83-
if innermost != nil {
84-
ctx.SubstitutionDepth = countUnclosedScopes(scopes)
85-
ctx.SubstitutionKind = innermost.Kind
86-
}
87-
return ctx
60+
return buildCompletionContext(tokens)
8861
}
8962

9063
// buildCompletionContext derives the completion context fields from a
91-
// token slice. It is called by SplitForCompletion with either the full
92-
// token slice (top-level) or the inner tokens of an unclosed substitution.
64+
// token slice.
9365
func buildCompletionContext(tokens TokenSlice) *CompletionContext {
9466
pipeline := tokens.CurrentPipeline()
9567
filtered := pipeline.FilterRedirects()
@@ -101,8 +73,6 @@ func buildCompletionContext(tokens TokenSlice) *CompletionContext {
10173
Pipeline: pipeline,
10274
}
10375

104-
// Detect redirect: if the second-to-last token in the pipeline is a redirect
105-
// wordbreak, the current word is a redirect target.
10676
if len(pipeline) >= 2 {
10777
prev := pipeline[len(pipeline)-2]
10878
if prev.WordbreakType.IsRedirect() {
@@ -111,8 +81,6 @@ func buildCompletionContext(tokens TokenSlice) *CompletionContext {
11181
}
11282

11383
if ctx.IsRedirect {
114-
// For redirects, the current word is the redirect target which was
115-
// filtered out of the words list. Get it from the raw pipeline.
11684
current := pipeline[len(pipeline)-1]
11785
ctx.CurrentWord = current.Value
11886
ctx.RawCurrentWord = current.RawValue
@@ -126,8 +94,6 @@ func buildCompletionContext(tokens TokenSlice) *CompletionContext {
12694

12795
ctx.Prefix = pipeline.WordbreakPrefix()
12896

129-
// Detect lambda parameter context: an odd number of WORDBREAK_LAMBDA_PIPE
130-
// tokens in the current pipeline means we're inside an unclosed {|...| parameter list.
13197
lambdaPipeCount := 0
13298
for _, t := range pipeline {
13399
if t.Type == WORDBREAK_TOKEN && t.WordbreakType == WORDBREAK_LAMBDA_PIPE {

format_elvish_test.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,6 @@ func TestElvishFormat_OutputCaptureCompletion(t *testing.T) {
409409
// Cursor inside output capture: echo (ls
410410
// The ( opens a substitution scope, so the inner context is "ls"
411411
ctx := SplitForCompletion("echo (ls", ElvishFormat())
412-
if ctx.SubstitutionDepth != 1 {
413-
t.Errorf("elvish output capture completion: SubstitutionDepth = %v, want 1", ctx.SubstitutionDepth)
414-
}
415412
if len(ctx.Words) != 1 || ctx.Words[0] != "ls" {
416413
t.Errorf("elvish output capture completion: Words = %v, want [ls]", ctx.Words)
417414
}

substitution.go

Lines changed: 28 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,151 +1,42 @@
11
package shlex
22

3-
import "encoding/json"
4-
5-
// SubstitutionKind classifies the type of substitution scope.
6-
type SubstitutionKind int
7-
8-
const (
9-
// SUBSTITUTION_COMMAND is $(...), (...), or similar command/output capture.
10-
SUBSTITUTION_COMMAND SubstitutionKind = iota
11-
// SUBSTITUTION_ARITHMETIC is $((...)).
12-
SUBSTITUTION_ARITHMETIC
13-
// SUBSTITUTION_BACKTICK is `...` (POSIX backtick command substitution).
14-
SUBSTITUTION_BACKTICK
15-
)
16-
17-
var substitutionKinds = map[SubstitutionKind]string{
18-
SUBSTITUTION_COMMAND: "SUBSTITUTION_COMMAND",
19-
SUBSTITUTION_ARITHMETIC: "SUBSTITUTION_ARITHMETIC",
20-
SUBSTITUTION_BACKTICK: "SUBSTITUTION_BACKTICK",
21-
}
22-
23-
func (k SubstitutionKind) MarshalJSON() ([]byte, error) {
24-
return json.Marshal(substitutionKinds[k])
25-
}
26-
27-
// SubstitutionScope describes a single substitution nesting level in the
28-
// token stream.
29-
type SubstitutionScope struct {
30-
OpenIndex int // TokenSlice index of the opener token
31-
CloseIndex int // TokenSlice index of the closer token, or -1 if unclosed
32-
Kind SubstitutionKind
33-
Depth int // nesting depth at this scope (1 = outermost)
34-
}
35-
36-
// SubstitutionScopes returns all substitution scopes in the token slice,
37-
// ordered by open position. Unclosed scopes (cursor inside) have
38-
// CloseIndex == -1. Backtick scopes are detected by scanning WORD_TOKEN
39-
// RawValue for unescaped backticks and have OpenIndex == -1 (since there
40-
// is no single opener token in the stream).
41-
func (t TokenSlice) SubstitutionScopes() []SubstitutionScope {
42-
var scopes []SubstitutionScope
43-
var stack []SubstitutionScope
44-
45-
for i, token := range t {
46-
switch {
47-
case token.WordbreakType == WORDBREAK_SUBSTITUTION_OPEN:
48-
kind := SUBSTITUTION_COMMAND
49-
// Detect arithmetic $(( : the opener RawValue contains two '('
50-
if len(token.RawValue) >= 2 && token.RawValue[len(token.RawValue)-2] == '(' {
51-
kind = SUBSTITUTION_ARITHMETIC
3+
// innermostUnclosedCommandScope returns the TokenSlice index of the opener
4+
// of the innermost unclosed command substitution scope, or -1 if the cursor
5+
// is at top level. Arithmetic ($((...))) and backtick scopes are not
6+
// command scopes — they don't produce an inner completion context.
7+
func innermostUnclosedCommandScope(tokens TokenSlice) int {
8+
depth := 0
9+
lastOpen := -1
10+
11+
for i, t := range tokens {
12+
switch t.WordbreakType {
13+
case WORDBREAK_SUBSTITUTION_OPEN:
14+
if isArithmeticOpener(t) {
15+
continue
5216
}
53-
scope := SubstitutionScope{
54-
OpenIndex: i,
55-
CloseIndex: -1,
56-
Kind: kind,
57-
Depth: len(stack) + 1,
17+
depth++
18+
lastOpen = i
19+
case WORDBREAK_SUBSTITUTION_CLOSE:
20+
if isArithmeticCloser(t) {
21+
continue
5822
}
59-
stack = append(stack, scope)
60-
61-
case token.WordbreakType == WORDBREAK_SUBSTITUTION_CLOSE:
62-
if len(stack) > 0 {
63-
scope := &stack[len(stack)-1]
64-
scope.CloseIndex = i
65-
scopes = append(scopes, *scope)
66-
stack = stack[:len(stack)-1]
23+
depth--
24+
if depth == 0 {
25+
lastOpen = -1
6726
}
6827
}
6928
}
7029

71-
// Any unclosed scopes remain on the stack
72-
for j := range stack {
73-
s := stack[j]
74-
scopes = append(scopes, s)
75-
}
76-
77-
// Scan for backtick substitution in WORD_TOKEN RawValues.
78-
// This is detect-only: backtick content is already merged into
79-
// word tokens by the tokenizer, so we can't extract inner tokens.
80-
scopes = append(scopes, t.detectBacktickScopes()...)
81-
82-
return scopes
83-
}
84-
85-
// detectBacktickScopes scans WORD_TOKENs for unescaped backticks.
86-
// An odd count of backticks in a single word's RawValue indicates an
87-
// unclosed backtick substitution starting at that word. The scope has
88-
// OpenIndex == -1 (no single opener token) and Kind == SUBSTITUTION_BACKTICK.
89-
func (t TokenSlice) detectBacktickScopes() []SubstitutionScope {
90-
var scopes []SubstitutionScope
91-
for i, token := range t {
92-
if token.Type != WORD_TOKEN {
93-
continue
94-
}
95-
count := countUnescapedBackticks(token.RawValue)
96-
if count%2 == 1 {
97-
scopes = append(scopes, SubstitutionScope{
98-
OpenIndex: -1,
99-
CloseIndex: -1,
100-
Kind: SUBSTITUTION_BACKTICK,
101-
Depth: 1,
102-
})
103-
_ = i // index kept for potential future use
104-
}
105-
}
106-
return scopes
107-
}
108-
109-
// countUnescapedBackticks counts backtick characters in s that are not
110-
// preceded by an escape character (backslash).
111-
func countUnescapedBackticks(s string) int {
112-
count := 0
113-
runes := []rune(s)
114-
for i, r := range runes {
115-
if r == '`' {
116-
if i > 0 && runes[i-1] == '\\' {
117-
continue // escaped backtick
118-
}
119-
count++
120-
}
30+
if depth > 0 {
31+
return lastOpen
12132
}
122-
return count
33+
return -1
12334
}
12435

125-
// innermostUnclosedScope returns the unclosed substitution scope with the
126-
// greatest depth, or nil if all scopes are closed. When the cursor is
127-
// inside a substitution, this is the scope whose content forms the inner
128-
// completion context.
129-
func innermostUnclosedScope(scopes []SubstitutionScope) *SubstitutionScope {
130-
var result *SubstitutionScope
131-
for i := range scopes {
132-
s := &scopes[i]
133-
if s.CloseIndex == -1 {
134-
if result == nil || s.Depth > result.Depth {
135-
result = s
136-
}
137-
}
138-
}
139-
return result
36+
func isArithmeticOpener(t Token) bool {
37+
return len(t.RawValue) >= 2 && t.RawValue[len(t.RawValue)-2] == '('
14038
}
14139

142-
// countUnclosedScopes returns the number of scopes with CloseIndex == -1.
143-
func countUnclosedScopes(scopes []SubstitutionScope) int {
144-
count := 0
145-
for _, s := range scopes {
146-
if s.CloseIndex == -1 {
147-
count++
148-
}
149-
}
150-
return count
40+
func isArithmeticCloser(t Token) bool {
41+
return len(t.RawValue) >= 2 && t.RawValue[0] == ')' && t.RawValue[1] == ')'
15142
}

0 commit comments

Comments
 (0)