Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ nu toolkit.nu release --major # major bump

**Important**: Always use `nu toolkit.nu test` (not `test-unit` or `test-integration` separately). The combined command provides proper test output and summary.

```bash
# Check test coverage - requires both source AND test files
nu -c 'use dotnu/; ["dotnu/*.nu" "tests/test_commands.nu" "toolkit.nu"] | each { glob $in } | flatten | dotnu dependencies ...$in | dotnu filter-commands-with-no-tests'
```

## Architecture

### Module Structure
Expand All @@ -38,11 +43,16 @@ dotnu/

**Export convention**: All commands in `commands.nu` are exported by default (for internal use, testing, and development). The public API is managed through `mod.nu`, which selectively re-exports only the user-facing commands. To add a command to the public API, add it to the list in `mod.nu`.

**Imports**:
- `use dotnu/` - import public API commands
- `use dotnu/commands.nu *` - import all commands (including internal)

**mod.nu** exports these public commands:
- `dependencies` - Analyze command call chains
- `extract-command-code` - Extract command with its dependencies
- `filter-commands-with-no-tests` - Find untested commands
- `list-exported-commands` - List module's exported commands
- `list-module-exports` - List all exported definitions (export def + export use)
- `list-module-interface` - List module's callable interface (main commands)
- `embeds-*` / `embed-add` - Literate programming tools
- `set-x` / `generate-numd` - Script profiling

Expand Down
55 changes: 28 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,13 @@ dotnu dependencies --help
# => ╰───┴───────┴────────╯
# =>
# => Examples:
# =>
# => Analyze command dependencies in a module
# => > dotnu dependencies ...(glob tests/assets/module-say/say/*.nu)
# => ╭───┬──────────┬────────────────────┬──────────┬──────╮
# => │ # │ caller │ filename_of_caller │ callee │ step │
# => ├───┼──────────┼────────────────────┼──────────┼──────┤
# => │ 0 │ hello │ hello.nu │ │ 0 │
# => │ 1 │ question │ ask.nu │ │ 0 │
# => │ 0 │ question │ ask.nu │ │ 0 │
# => │ 1 │ hello │ hello.nu │ │ 0 │
# => │ 2 │ say │ mod.nu │ hello │ 0 │
# => │ 3 │ say │ mod.nu │ hi │ 0 │
# => │ 4 │ say │ mod.nu │ question │ 0 │
Expand Down Expand Up @@ -240,13 +240,13 @@ dotnu filter-commands-with-no-tests --help
# => ╰───┴───────┴────────╯
# =>
# => Examples:
# =>
# => Find commands not covered by tests
# => > dependencies ...(glob tests/assets/module-say/say/*.nu) | filter-commands-with-no-tests
# => ╭───┬──────────┬────────────────────╮
# => │ # │ caller │ filename_of_caller │
# => ├───┼──────────┼────────────────────┤
# => │ 0 │ hello │ hello.nu │
# => │ 1 │ question │ ask.nu
# => │ 0 │ question │ ask.nu
# => │ 1 │ hello │ hello.nu │
# => │ 2 │ say │ mod.nu │
# => ╰───┴──────────┴────────────────────╯
# =>
Expand Down Expand Up @@ -283,7 +283,7 @@ dotnu set-x --help
# => ╰───┴───────┴────────╯
# =>
# => Examples:
# =>
# => Generate script with timing instrumentation
# => > set-x tests/assets/set-x-demo.nu --echo | lines | first 3 | to text
# => mut $prev_ts = ( date now )
# => print ("> sleep 0.5sec" | nu-highlight)
Expand Down Expand Up @@ -397,29 +397,30 @@ dotnu extract-command-code --help
# =>
```

### `dotnu list-exported-commands`
### `dotnu list-module-exports`

List commands defined in a module file. Use `--export` to show only exported commands.
List all exported definitions from a module file. Finds commands from `export def` and `export use [...commands]` patterns.

```nushell
dotnu list-exported-commands --help
# => Usage:
# => > list-exported-commands {flags} <$path>
# =>
# => Flags:
# => -h, --help: Display the help message for this command
# => --export: use only commands that are exported
# =>
# => Parameters:
# => $path <path>
# =>
# => Input/output types:
# => ╭───┬───────┬────────╮
# => │ # │ input │ output │
# => ├───┼───────┼────────┤
# => │ 0 │ any │ any │
# => ╰───┴───────┴────────╯
# =>
dotnu list-module-exports dotnu/mod.nu | first 5
# => ╭───┬──────────────────────╮
# => │ 0 │ dependencies │
# => │ 1 │ embed-add │
# => │ 2 │ embeds-capture-start │
# => │ 3 │ embeds-capture-stop │
# => │ 4 │ embeds-remove │
# => ╰───┴──────────────────────╯
```

### `dotnu list-module-interface`

List module's callable interface - the `main` and `main subcommand` patterns that become available when you `use` the module.

```nushell
dotnu list-module-interface tests/assets/b/example-mod1.nu
# => ╭───┬──────╮
# => │ 0 │ main │
# => ╰───┴──────╯
```

### `dotnu module-commands-code-to-record`
Expand Down
150 changes: 107 additions & 43 deletions dotnu/commands.nu
Original file line number Diff line number Diff line change
Expand Up @@ -162,24 +162,34 @@ export def 'extract-command-code' [
}
}

# todo: `list-exported-commands` should be a completion for Nushell CLI
# List all exported definitions from a module file
#
# Finds commands from `export def` and `export use [...commands]` patterns.
export def 'list-module-exports' [
$path: path
]: nothing -> list<string> {
open $path -r
| extract-exported-commands
| replace-main-with-module-name $path
| if ($in | is-empty) {
print 'No command found'
return
} else { }
}

export def 'list-exported-commands' [
# List module's callable interface (main commands)
#
# Finds `def main` and `def 'main subcommand'` patterns - the commands
# available when you `use` the module.
export def 'list-module-interface' [
$path: path
--export # use only commands that are exported
] {
]: nothing -> list<string> {
open $path -r
| lines
| if $export {
where $it =~ '^export def '
| extract-command-name
| replace-main-with-module-name $path
} else {
where $it =~ '^(export )?def '
| extract-command-name
| where $it starts-with 'main'
| str replace 'main ' ''
}
| where $it =~ '^(export )?def '
| extract-command-name
| where $it starts-with 'main'
| str replace 'main ' ''
| if ($in | is-empty) {
print 'No command found'
return
Expand Down Expand Up @@ -259,8 +269,10 @@ export def 'examples-update' [
# Using full original text ensures unique matches even with duplicate results
let updated = $results | reduce --fold $content {|item acc|
# Build new example by replacing just the result value in the original
# Escape $ as $$ to prevent regex backreference interpretation
let escaped_result = $item.new_result | str replace -a '$' '$$'
let new_example = $item.original
| str replace -r '\} --result .+$' $"} --result ($item.new_result)"
| str replace -r '\} --result .+$' $"} --result ($escaped_result)"

$acc | str replace $item.original $new_example
}
Expand Down Expand Up @@ -288,10 +300,12 @@ export def find-examples []: string -> table<original: string, code: string, res
| enumerate
| window 2
| where {|pair|
($pair.0.item.shape == "shape_gap" and ($pair.0.item.content | str ends-with "@")
and $pair.1.item.content == "example")
(
$pair.0.item.shape == "shape_gap" and ($pair.0.item.content | str ends-with "@")
and $pair.1.item.content == "example"
)
}
| each { $in.1.index } # Get index of "example" token
| each { $in.1.index } # Get index of "example" token

if ($example_indices | is-empty) {
return []
Expand All @@ -304,7 +318,7 @@ export def find-examples []: string -> table<original: string, code: string, res
# Find block tokens (shape_block) - opening and closing braces
let block_tokens = $remaining
| enumerate
| where {|r| $r.item.shape == "shape_block"}
| where {|r| $r.item.shape == "shape_block" }

if ($block_tokens | length) < 2 {
# Malformed @example - skip
Expand All @@ -320,17 +334,41 @@ export def find-examples []: string -> table<original: string, code: string, res

# Skip whitespace/newlines to find the flag
let after_block_meaningful = $after_block
| where shape not-in ["shape_whitespace", "shape_newline"]
| where shape not-in ["shape_whitespace" "shape_newline"]

let result_info = if ($after_block_meaningful | is-not-empty) and ($after_block_meaningful | first | get shape) == "shape_flag" and ($after_block_meaningful | first | get content) == "--result" {
# Has --result flag - get the value token (skip whitespace after flag)
# Has --result flag - get the value token(s) (skip whitespace after flag)
let result_tokens = $after_block_meaningful | skip 1
| where shape not-in ["shape_whitespace", "shape_newline"]
let result_value = $result_tokens | first
| where shape not-in ["shape_whitespace" "shape_newline"]
let first_token = $result_tokens | first

# For lists/records, find matching closing bracket
let end_byte = if $first_token.content == "[" or $first_token.content == "{" {
let close_char = if $first_token.content == "[" { "]" } else { "}" }
let open_char = $first_token.content
# Find matching close by tracking bracket depth
let close_token = $result_tokens | skip 1
| reduce --fold {depth: 1 token: null} {|t, acc|
if $acc.token != null { $acc } else {
let new_depth = if $t.content == $open_char {
$acc.depth + 1
} else if $t.content == $close_char {
$acc.depth - 1
} else {
$acc.depth
}
if $new_depth == 0 { {depth: 0 token: $t} } else { {depth: $new_depth token: null} }
}
}
$close_token.token.end
} else {
$first_token.end
}

{
has_result: true
end_byte: $result_value.end
result_line: $"} --result ($result_value.content)"
end_byte: $end_byte
result_line: "" # not used, kept for interface compatibility
}
} else {
# No --result flag
Expand All @@ -348,8 +386,8 @@ export def find-examples []: string -> table<original: string, code: string, res

# Extract original text from @ to end of result value
# The @ may be at end of a gap that includes newlines (e.g., "\n\n@")
let at_token = $tokens | get ($idx - 1) # The gap token containing @
let at_start = $at_token.end - 1 # @ is always the last char in the gap
let at_token = $tokens | get ($idx - 1) # The gap token containing @
let at_start = $at_token.end - 1 # @ is always the last char in the gap
let original = $bytes | bytes at $at_start..<($result_info.end_byte) | decode utf8

# Extract code from inside the block (between { and })
Expand Down Expand Up @@ -689,10 +727,10 @@ export def list-module-commands [
}
| each {|pair|
let attr_token = $pair.1.item
let at_start = $pair.0.item.end - 1 # @ is last char in the gap
{ caller: ('@' + ($attr_token.content | split row ' ' | first)), start: $at_start }
let at_start = $pair.0.item.end - 1 # @ is last char in the gap
{caller: ('@' + ($attr_token.content | split row ' ' | first)) start: $at_start}
}
| insert end null # attributes don't have scope ranges
| insert end null # attributes don't have scope ranges

let defined_defs = $def_definitions
| append $attribute_definitions
Expand Down Expand Up @@ -962,6 +1000,36 @@ export def capture-marker [
}
}

# Extract exported command names using AST
#
# Handles both patterns:
# - `export def cmd-name []` → extracts cmd-name
# - `export use module.nu ["cmd1" "cmd2"]` → extracts cmd1, cmd2
export def extract-exported-commands []: string -> list<string> {
let tokens = ast --flatten $in | flatten span

$tokens
| enumerate
| where item.content in ['export def' 'export use']
| each {|match|
let idx = $match.index
if $match.item.content == 'export def' {
# Command name is next token
$tokens | get ($idx + 1) | get content | str trim -c "'" | str trim -c '"'
} else {
# export use: skip module path, get shape_string tokens from list
$tokens
| skip ($idx + 2)
| take while { $in.shape in ['shape_string' 'shape_list'] }
| where shape == 'shape_string'
| get content
| str trim -c '"'
| str trim -c "'"
}
}
| flatten
}

# Complete AST output by filling gaps with synthetic tokens
#
# `ast --flatten` omits certain syntax elements (semicolons, assignment operators, etc).
Expand Down Expand Up @@ -991,7 +1059,7 @@ export def ast-complete []: string -> table {
| where {|p| $p.0.end < $p.1.start }
| each {|p|
let content = $bytes | bytes at $p.0.end..<$p.1.start | decode utf8
{content: $content, start: $p.0.end, end: $p.1.start, shape: (classify-gap $content)}
{content: $content start: $p.0.end end: $p.1.start shape: (classify-gap $content)}
}

$tokens | select content start end shape | append $gaps | sort-by start
Expand Down Expand Up @@ -1045,22 +1113,18 @@ export def split-statements []: string -> table<statement: string, start: int, e
for token in $tokens {
# Track block depth
# Handle blocks where { and } may be in same token (e.g., "{}" or "{ x }")
if $token.shape in ["shape_block", "shape_closure"] {
let has_open = $token.content | str contains "{"
let has_close = $token.content | str contains "}"
if $has_open and $has_close {
# Self-contained block like {} - no net depth change
} else if $has_open {
$depth = $depth + 1
} else if $has_close {
$depth = $depth - 1
}
if $token.shape in ["shape_block" "shape_closure" "shape_gap"] {
let opens = ($token.content | split row "{" | length) - 1
let closes = ($token.content | split row "}" | length) - 1
$depth = $depth + $opens - $closes
}

# Statement boundary at top level
# Also check shape_gap that starts with newline (comments are bundled into gaps)
let is_boundary = ($token.shape in ["shape_semicolon", "shape_newline"]
or ($token.shape == "shape_gap" and ($token.content | str starts-with "\n")))
let is_boundary = (
$token.shape in ["shape_semicolon" "shape_newline"]
or ($token.shape == "shape_gap" and ($token.content | str starts-with "\n"))
)
if $depth == 0 and $is_boundary {
let stmt_text = $bytes | bytes at $stmt_start..<$token.start | decode utf8 | str trim
if ($stmt_text | is-not-empty) {
Expand Down
3 changes: 2 additions & 1 deletion dotnu/mod.nu
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export use commands.nu [
"extract-command-code"
"filter-commands-with-no-tests"
"generate-numd"
"list-exported-commands"
"list-module-exports"
"list-module-interface"
"module-commands-code-to-record"
"set-x"
]
9 changes: 6 additions & 3 deletions tests/output-yaml/coverage-untested.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
# untested: ($untested | get caller)
# }
# | to yaml
public_api_count: 14
tested_count: 14
untested: []
public_api_count: 15
tested_count: 12
untested:
- embed-add
- embeds-capture-start
- embeds-capture-stop
Loading