diff --git a/ls/server/src/connection/README.md b/ls/server/src/connection/README.md index 1634e7e..ea7f00d 100644 --- a/ls/server/src/connection/README.md +++ b/ls/server/src/connection/README.md @@ -44,9 +44,10 @@ and retains normal function resolution. ## Argument documentation Signature help and call-argument hover use `SimpleCommand.arguments`. A quoted -or multiline argument remains one syntax word. Bare documented `name=value` -arguments select their named parameter; positional words select their ordinal -parameter and then a documented rest parameter. This describes syntax words; +or multiline argument remains one syntax word. Documented `name=value` +arguments select their named parameter after shell quoting is removed from the +static key, including `"name=value"` and `"name=$value"`; positional words select +their ordinal parameter and then a documented rest parameter. This describes syntax words; runtime expansion can produce a different number of shell arguments. Within a function, argument hover recognizes direct positional parameter parts diff --git a/ls/server/src/connection/ast/word.ts b/ls/server/src/connection/ast/word.ts index 9370cde..d128662 100644 --- a/ls/server/src/connection/ast/word.ts +++ b/ls/server/src/connection/ast/word.ts @@ -1,4 +1,5 @@ import type { WordNode } from '../../zshParser/ast'; +import { hereDocDelimiter } from '../../zshParser/lexer/delimiter'; const RESERVED_COMMANDS: readonly string[] = [ 'if', 'then', 'else', 'elif', 'fi', 'for', 'select', 'foreach', 'repeat', @@ -15,8 +16,11 @@ export function commandInsertion(name: string): string { return "'" + name.split("'").join(escapedQuote) + "'"; } +/** Read a named key from the static word prefix, removing shell quoting without evaluating expansions. */ export function namedKey(word: WordNode): string | null { - const text = word.raw; + const prefix = word.parts[0]; + if (prefix?.kind !== 'stringLiteral') return null; + const text = hereDocDelimiter(prefix.value).delimiter; let index = 0; while (index < text.length) { const char = text[index]; diff --git a/ls/server/src/diagnostics/README.md b/ls/server/src/diagnostics/README.md index d47d5ca..f39cabd 100644 --- a/ls/server/src/diagnostics/README.md +++ b/ls/server/src/diagnostics/README.md @@ -56,9 +56,11 @@ shell environment was obtained successfully. ## Parameter contracts Required arguments come from `documentationOf(definition).parameters`. Each -syntax word supplies one positional argument unless it has a documented bare -`name=value` key. Position numbers, optional parameters and rest parameters are -read directly from documentation nodes. A required `$3` needs three positional +syntax word supplies one positional argument unless its static prefix has a +documented `name=value` key after shell quote removal. This includes +`name="value"`, `"name=value"` and `"name=$value"`. A key that depends on an +expansion is not treated as a statically known name. Position numbers, optional +parameters and rest parameters are read directly from documentation nodes. A required `$3` needs three positional words even if `$1` and `$2` are absent from the documentation. Quoted scalar values count as one word. Words whose expansions or global aliases diff --git a/ls/server/test/connection/providers.test.ts b/ls/server/test/connection/providers.test.ts index 4568bc2..04e531f 100644 --- a/ls/server/test/connection/providers.test.ts +++ b/ls/server/test/connection/providers.test.ts @@ -130,6 +130,10 @@ describe('signature help@AST', () => { ['greet ¦', 0], ['greet "hello world" ¦', 1], ['greet "hello\nworld" ¦', 1], ['greet a ' + String.fromCharCode(92) + '\n ¦', 1], ['greet one=value ¦', 0], ['greet two=val¦ue', 3], + ['greet "one=value" ¦', 0], ['greet "two=val¦ue"', 3], + ['greet "two=$val¦ue"', 3], ["greet 'two=val¦ue'", 3], + ['greet "two=value" one=value ¦', 0], ['greet "two=value" first ¦', 1], + ['greet "${key}=val¦ue"', 0], ['greet "tw${key}=val¦ue"', 0], ['greet "hello\nworld" ¦', 1], ['greet a ' + String.fromCharCode(92) + '\n ¦', 1], ['print "$(greet a ¦)"', 1], ['print x | greet ¦', 0], ['greet a; greet ¦', 0], @@ -176,6 +180,12 @@ describe('argument hover@AST', () => { contents: { value: expect.stringContaining('first') }, }); }); + test.each(['greet "two=val¦ue"', 'greet "two=$val¦ue"', "greet 'two=val¦ue'", + 'greet "one=value" "two=val¦ue"'])('引用した名前付き引数の説明を返す: %s', source => { + expect(fixture(source).hover()).toMatchObject({ + contents: { value: expect.stringContaining('option two') }, + }); + }); test.each(['print "$¦1"', 'local value="$1"; print "$val¦ue"', 'local value=${1}; print "$val¦ue"', 'local value=$1; print "${val¦ue}"'])('位置引数・そのalias: %s', body => { const f = fixture('# $1: local first\nowner() { ' + body + ' }\n'); diff --git a/ls/server/test/connection/server.test.ts b/ls/server/test/connection/server.test.ts index 9a2749e..d869b6a 100644 --- a/ls/server/test/connection/server.test.ts +++ b/ls/server/test/connection/server.test.ts @@ -322,6 +322,38 @@ describe('server@hydration stdio', () => { } }, 15000); + test('引用した名前付き引数の診断・署名・hoverを実サーバーで返す', async () => { + const root = fixture(); + const caller = pathToFileURL(path.join(root, 'caller.zsh')).href; + fs.writeFileSync(path.join(root, 'definition.zsh'), [ + '# $1: first', '# $message: error message', + 'z.t._log.failure.handle() { print "$1" "$message" }', '', + ].join('\n')); + const client = startServer(); + const source = 'z.t._log.failure.handle "message=failed: expected [ $expect_display ] but got [ $actual_display ]" first\n'; + const request = () => bounded(client.connection.sendRequest(DocumentDiagnosticRequest.type, { + textDocument: { uri: caller }, + })); + try { + await client.initialize(root, false); + await client.open(caller, source); + expect(await request()).toMatchObject({ kind: 'full', items: [] }); + const position = { line: 0, character: source.indexOf('expected') }; + expect(await bounded(client.connection.sendRequest(SignatureHelpRequest.type, { + textDocument: { uri: caller }, position, + }))).toMatchObject({ activeParameter: 1, signatures: [{ activeParameter: 1 }] }); + expect(await bounded(client.connection.sendRequest(HoverRequest.type, { + textDocument: { uri: caller }, position, + }))).toMatchObject({ contents: { value: expect.stringContaining('error message') } }); + await client.edit(caller, 'z.t._log.failure.handle first\n', 2); + expect(await request()).toMatchObject({ kind: 'full', items: [{ + code: 'missing-named-argument', message: 'Missing required named argument: message', + }] }); + } finally { + await client.close(); + } + }, 15000); + test('AST診断・環境解決・抑制・依存先変更を実サーバーで反映する', async () => { const root = fixture(); const definition = path.join(root, 'definition.zsh'); diff --git a/ls/server/test/diagnostics/main.test.ts b/ls/server/test/diagnostics/main.test.ts index 6d4a763..0043d98 100644 --- a/ls/server/test/diagnostics/main.test.ts +++ b/ls/server/test/diagnostics/main.test.ts @@ -74,7 +74,29 @@ describe('call diagnostics@AST', () => { const doc = '# $option: value\ngreet() { print "$option" }\n'; expect(semantic(fixture('greet option=""\n', doc).items)).toEqual([]); expect(semantic(fixture('greet option=\n', doc).items)).toEqual([]); - expect(semantic(fixture('greet "option=value"\n', doc).items)).toMatchObject([{ code: 'missing-named-argument' }]); + expect(semantic(fixture('greet "option=value"\n', doc).items)).toEqual([]); + }); + test.each(['"option=value with spaces"', '"option="', '"option=$value"', + "'option=value with spaces'", 'op"tion"=value', '"option"="$value"', + 'option="value"', 'op\\tion=value'])('引用を除いた静的な名前付き引数を照合する: %s', argument => { + const doc = '# $option: value\ngreet() { print "$option" }\n'; + expect(semantic(fixture('greet ' + argument + '\n', doc).items)).toEqual([]); + }); + test.each(['"other=value"', '"9option=value"', '"option-extra=value"', '"option"'])('引用しても不足した名前付き引数を検出する: %s', argument => { + const doc = '# $option: value\ngreet() { print "$option" }\n'; + expect(semantic(fixture('greet ' + argument + '\n', doc).items)) + .toMatchObject([{ code: 'missing-named-argument', message: 'Missing required named argument: option' }]); + }); + test('引用した名前付き引数を位置引数へ数えない', () => { + const doc = '# $1: first\n# $option: value\ngreet() { print "$1" "$option" }\n'; + expect(semantic(fixture('greet "option=value"\n', doc).items)) + .toMatchObject([{ code: 'missing-positional-argument', message: 'Missing required argument(s): $1' }]); + expect(semantic(fixture('greet "option=$value" first\n', doc).items)).toEqual([]); + }); + test('失敗ログの実例を名前付き引数として認識する', () => { + const doc = '# $message: error message\nz.t._log.failure.handle() { print "$message" }\n'; + const source = 'z.t._log.failure.handle "message=failed: expected [ $expect_display ] but got [ $actual_display ]"\n'; + expect(semantic(fixture(source, doc).items)).toEqual([]); }); });