Compare commits

..

4 Commits

Author SHA1 Message Date
bb92a9e0b4 fix edge case 2025-11-06 21:23:20 -08:00
e915868b7c interpolation in { curly strings } 2025-11-06 21:04:23 -08:00
5b965326e4 curly -> Curly 2025-11-06 13:32:31 -08:00
2383a39687 { curly strings } 2025-11-06 11:01:46 -08:00
12 changed files with 18 additions and 49 deletions

View File

@ -200,7 +200,7 @@ function parseExpression(input: string) {
- **Not in scope** → Parses as `Word("obj.prop")` → compiles to `PUSH 'obj.prop'` (treated as file path/string)
Implementation files:
- **src/parser/parserScopeContext.ts**: ContextTracker that maintains immutable scope chain
- **src/parser/scopeTracker.ts**: ContextTracker that maintains immutable scope chain
- **src/parser/tokenizer.ts**: External tokenizer checks `stack.context` to decide if dot creates DotGet or Word
- Scope tracking: Captures variables from assignments (`x = 5`) and function parameters (`fn x:`)
- See `src/parser/tests/dot-get.test.ts` for comprehensive examples

View File

@ -78,18 +78,4 @@ describe('pipe expressions', () => {
div = do a b: a / b end
sub 3 1 | div (sub 110 9 | sub 1) _ | div 5`).toEvaluateTo(10)
})
test('pipe with prelude functions (list.reverse and list.map)', () => {
expect(`
double = do x: x * 2 end
range 1 3 | list.reverse | list.map double
`).toEvaluateTo([6, 4, 2])
})
test('pipe with prelude function (echo)', () => {
expect(`
get-msg = do: 'hello' end
get-msg | length
`).toEvaluateTo(5)
})
})

View File

@ -83,7 +83,7 @@ end
test('custom tags', () => {
expect(`
list = tag ul class='list'
list = tag ul class=list
ribbit:
list:
li border-bottom='1px solid black' one

View File

@ -1,6 +1,6 @@
@external propSource highlighting from "./highlight"
@context trackScope from "./parserScopeContext"
@context trackScope from "./scopeTracker"
@skip { space | Comment }

View File

@ -2,7 +2,7 @@
import {LRParser, LocalTokenGroup} from "@lezer/lr"
import {operatorTokenizer} from "./operatorTokenizer"
import {tokenizer, specializeKeyword} from "./tokenizer"
import {trackScope} from "./parserScopeContext"
import {trackScope} from "./scopeTracker"
import {highlighting} from "./highlight"
const spec_Identifier = {__proto__:null,if:68, null:96, catch:102, finally:108, end:110, else:118, while:132, try:138, throw:142}
export const parser = LRParser.deserialize({

View File

@ -189,9 +189,7 @@ const checkForDotGet = (input: InputStream, stack: Stack, pos: number): number |
// If identifier is in scope, this is property access (e.g., obj.prop)
// If not in scope, it should be consumed as a Word (e.g., file.txt)
return context?.scope.has(identifierText) || globals.includes(identifierText)
? IdentifierBeforeDot
: null
return context?.scope.has(identifierText) || globals.includes(identifierText) ? IdentifierBeforeDot : null
}
// Decide between AssignableIdentifier and Identifier using grammar state + peek-ahead
@ -219,10 +217,7 @@ const chooseIdentifierToken = (input: InputStream, stack: Stack): number => {
const nextCh2 = getFullCodePoint(input, peekPos + 1)
// Check for compound assignment operators: +=, -=, *=, /=, %=
if (
[43 /* + */, 45 /* - */, 42 /* * */, 47 /* / */, 37 /* % */].includes(nextCh) &&
nextCh2 === 61 /* = */
) {
if ([43/* + */, 45/* - */, 42/* * */, 47/* / */, 37/* % */].includes(nextCh) && nextCh2 === 61/* = */) {
// Found compound operator, check if it's followed by whitespace
const charAfterOp = getFullCodePoint(input, peekPos + 2)
if (isWhiteSpace(charAfterOp) || charAfterOp === -1 /* EOF */) {

View File

@ -1,7 +1,6 @@
import { expect } from 'bun:test'
import { parser } from '#parser/shrimp'
import { setGlobals } from '#parser/tokenizer'
import { globals as prelude } from '#prelude'
import { $ } from 'bun'
import { assert, errorMessage } from '#utils/utils'
import { Compiler } from '#compiler/compiler'
@ -44,8 +43,7 @@ expect.extend({
toMatchTree(received: unknown, expected: string, globals?: Record<string, any>) {
assert(typeof received === 'string', 'toMatchTree can only be used with string values')
const allGlobals = { ...prelude, ...(globals || {}) }
setGlobals(Object.keys(allGlobals))
if (globals) setGlobals(Object.keys(globals))
const tree = parser.parse(received)
const actual = treeToString(tree, received)
const normalizedExpected = trimWhitespace(expected)
@ -101,10 +99,9 @@ expect.extend({
assert(typeof received === 'string', 'toEvaluateTo can only be used with string values')
try {
const allGlobals = { ...prelude, ...(globals || {}) }
setGlobals(Object.keys(allGlobals))
if (globals) setGlobals(Object.keys(globals))
const compiler = new Compiler(received)
const result = await run(compiler.bytecode, allGlobals)
const result = await run(compiler.bytecode, globals)
let value = VMResultToValue(result)
// Just treat regex as strings for comparison purposes

View File

@ -1,10 +1,10 @@
import { test, expect, describe } from 'bun:test'
import { EditorScopeAnalyzer } from './editorScopeAnalyzer'
import { ScopeTracker } from './scopeTracker'
import { TextDocument } from 'vscode-languageserver-textdocument'
import { parser } from '../../../src/parser/shrimp'
import * as Terms from '../../../src/parser/shrimp.terms'
describe('EditorScopeAnalyzer', () => {
describe('ScopeTracker', () => {
test('top-level assignment is in scope', () => {
const code = 'x = 5\necho x'
const { tree, tracker } = parseAndGetScope(code)
@ -135,17 +135,11 @@ end`
const xInEcho = identifiers[identifiers.length - 1]
expect(tracker.isInScope('x', xInEcho)).toBe(true)
})
test('the prelude functions are always in scope', () => {
const code = `echo "Hello, World!"`
const { tree, tracker } = parseAndGetScope(code)
expect(tracker.isInScope('echo', tree.topNode)).toBe(true)
})
})
const parseAndGetScope = (code: string) => {
const document = TextDocument.create('test://test.sh', 'shrimp', 1, code)
const tree = parser.parse(code)
const tracker = new EditorScopeAnalyzer(document)
const tracker = new ScopeTracker(document)
return { document, tree, tracker }
}

View File

@ -1,20 +1,17 @@
import { SyntaxNode } from '@lezer/common'
import { TextDocument } from 'vscode-languageserver-textdocument'
import * as Terms from '../../../src/parser/shrimp.terms'
import { globals } from '../../../src/prelude'
/**
* Tracks variables in scope at a given position in the parse tree.
* Used to distinguish identifiers (in scope) from words (not in scope).
*/
export class EditorScopeAnalyzer {
export class ScopeTracker {
private document: TextDocument
private scopeCache = new Map<number, Set<string>>()
constructor(document: TextDocument) {
this.document = document
const preludeKeys = Object.keys(globals)
this.scopeCache.set(0, new Set(preludeKeys))
}
/**

View File

@ -7,7 +7,7 @@ import {
SemanticTokenTypes,
SemanticTokenModifiers,
} from 'vscode-languageserver/node'
import { EditorScopeAnalyzer } from './editorScopeAnalyzer'
import { ScopeTracker } from './scopeTracker'
export const TOKEN_TYPES = [
SemanticTokenTypes.function,
@ -32,7 +32,7 @@ export function buildSemanticTokens(document: TextDocument): number[] {
const text = document.getText()
const tree = parser.parse(text)
const builder = new SemanticTokensBuilder()
const scopeTracker = new EditorScopeAnalyzer(document)
const scopeTracker = new ScopeTracker(document)
walkTree(tree.topNode, document, builder, scopeTracker)
@ -77,7 +77,7 @@ function walkTree(
node: SyntaxNode,
document: TextDocument,
builder: SemanticTokensBuilder,
scopeTracker: EditorScopeAnalyzer
scopeTracker: ScopeTracker
) {
// Special handling for NamedArgPrefix to split "name=" into two tokens
if (node.type.id === Terms.NamedArgPrefix) {
@ -104,7 +104,7 @@ type TokenInfo = { type: number; modifiers: number } | undefined
function getTokenType(
node: SyntaxNode,
document: TextDocument,
scopeTracker: EditorScopeAnalyzer
scopeTracker: ScopeTracker
): TokenInfo {
const nodeTypeId = node.type.id
const parentTypeId = node.parent?.type.id